├── test ├── __init__.py ├── test_transmuters.py ├── test_maze.py └── test_solvers.py ├── mazelib ├── solve │ ├── __init__.py │ ├── BacktrackingSolver.pxd │ ├── RandomMouse.pxd │ ├── Tremaux.pxd │ ├── ShortestPaths.pxd │ ├── ShortestPath.pxd │ ├── Collision.pxd │ ├── Chain.pxd │ ├── BacktrackingSolver.py │ ├── MazeSolveAlgo.pxd │ ├── RandomMouse.py │ ├── Collision.py │ ├── Tremaux.py │ ├── ShortestPaths.py │ ├── ShortestPath.py │ ├── MazeSolveAlgo.py │ └── Chain.py ├── generate │ ├── __init__.py │ ├── Sidewinder.pxd │ ├── AldousBroder.pxd │ ├── BacktrackingGenerator.pxd │ ├── MazeGenAlgo.pxd │ ├── Prims.pxd │ ├── GrowingTree.pxd │ ├── Kruskal.pxd │ ├── CellularAutomaton.pxd │ ├── BinaryTree.pxd │ ├── Division.pxd │ ├── TrivialMaze.pxd │ ├── HuntAndKill.pxd │ ├── Ellers.pxd │ ├── Wilsons.pxd │ ├── BacktrackingGenerator.py │ ├── MazeGenAlgo.py │ ├── DungeonRooms.pxd │ ├── AldousBroder.py │ ├── BinaryTree.py │ ├── GrowingTree.py │ ├── Prims.py │ ├── Sidewinder.py │ ├── CellularAutomaton.py │ ├── Kruskal.py │ ├── Division.py │ ├── TrivialMaze.py │ ├── HuntAndKill.py │ ├── Ellers.py │ └── Wilsons.py ├── transmute │ ├── __init__.py │ ├── CuldeSacFiller.pxd │ ├── MazeTransmuteAlgo.pxd │ ├── DeadEndFiller.pxd │ ├── Perturbation.pxd │ ├── CuldeSacFiller.py │ ├── MazeTransmuteAlgo.py │ ├── DeadEndFiller.py │ └── Perturbation.py ├── __init__.py └── mazelib.py ├── .codecov.yml ├── docs ├── images │ ├── css_4x5.png │ ├── ellers_7x7.gif │ ├── ellers_7x7.png │ ├── prims_7x7.gif │ ├── prims_7x7.png │ ├── spiral_1.png │ ├── xkcd_5x6.png │ ├── division_7x7.gif │ ├── division_7x7.png │ ├── kruskal_7x7.gif │ ├── kruskal_7x7.png │ ├── wilsons_7x7.gif │ ├── wilsons_7x7.png │ ├── binary_tree_7x7.gif │ ├── binary_tree_7x7.png │ ├── cell_auto_7x7.gif │ ├── cell_auto_7x7.png │ ├── dead_end_steps.png │ ├── huntandkill_7x7.gif │ ├── huntandkill_7x7.png │ ├── perturbation_1.png │ ├── prims_5x5_plain.png │ ├── sidewinder_7x7.gif │ ├── sidewinder_7x7.png │ ├── aldous_broder_7x7.gif │ ├── aldous_broder_7x7.png │ ├── backtracking_7x7.gif │ ├── backtracking_7x7.png │ ├── dungeon_rooms_7x7.gif │ ├── dungeon_rooms_7x7.png │ ├── growing_tree_7x7.gif │ ├── growing_tree_7x7.png │ ├── cul_de_sac_filling.png │ ├── dungeon_rooms_11x11.gif │ ├── dungeon_rooms_11x11.png │ ├── perturbation_sprial.png │ └── dungeon_rooms_4x4_plain.png ├── MAZE_TRANSMUTE_ALGOS.md ├── API.md ├── MAZE_SOLVE_ALGOS.md └── EXAMPLES.md ├── .github └── workflows │ ├── black.yaml │ ├── ruff.yaml │ ├── coverage.yaml │ ├── unittests.yaml │ ├── wheels.yaml │ └── stale.yaml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── benchmarks.py └── pyproject.toml /test/__init__.py: -------------------------------------------------------------------------------- 1 | """The Mazelib unit test package.""" 2 | -------------------------------------------------------------------------------- /mazelib/solve/__init__.py: -------------------------------------------------------------------------------- 1 | """Algorithm to solve mazes.""" 2 | -------------------------------------------------------------------------------- /mazelib/generate/__init__.py: -------------------------------------------------------------------------------- 1 | """Algorithms to generate mazes.""" 2 | -------------------------------------------------------------------------------- /.codecov.yml: -------------------------------------------------------------------------------- 1 | ignore: 2 | - "test/" 3 | - benchmarks.py 4 | - setup.py 5 | 6 | -------------------------------------------------------------------------------- /mazelib/transmute/__init__.py: -------------------------------------------------------------------------------- 1 | """Algorithms to transmute a maze, and leave it solvable.""" 2 | -------------------------------------------------------------------------------- /docs/images/css_4x5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/john-science/mazelib/HEAD/docs/images/css_4x5.png -------------------------------------------------------------------------------- /docs/images/ellers_7x7.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/john-science/mazelib/HEAD/docs/images/ellers_7x7.gif -------------------------------------------------------------------------------- /docs/images/ellers_7x7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/john-science/mazelib/HEAD/docs/images/ellers_7x7.png -------------------------------------------------------------------------------- /docs/images/prims_7x7.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/john-science/mazelib/HEAD/docs/images/prims_7x7.gif -------------------------------------------------------------------------------- /docs/images/prims_7x7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/john-science/mazelib/HEAD/docs/images/prims_7x7.png -------------------------------------------------------------------------------- /docs/images/spiral_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/john-science/mazelib/HEAD/docs/images/spiral_1.png -------------------------------------------------------------------------------- /docs/images/xkcd_5x6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/john-science/mazelib/HEAD/docs/images/xkcd_5x6.png -------------------------------------------------------------------------------- /docs/images/division_7x7.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/john-science/mazelib/HEAD/docs/images/division_7x7.gif -------------------------------------------------------------------------------- /docs/images/division_7x7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/john-science/mazelib/HEAD/docs/images/division_7x7.png -------------------------------------------------------------------------------- /docs/images/kruskal_7x7.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/john-science/mazelib/HEAD/docs/images/kruskal_7x7.gif -------------------------------------------------------------------------------- /docs/images/kruskal_7x7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/john-science/mazelib/HEAD/docs/images/kruskal_7x7.png -------------------------------------------------------------------------------- /docs/images/wilsons_7x7.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/john-science/mazelib/HEAD/docs/images/wilsons_7x7.gif -------------------------------------------------------------------------------- /docs/images/wilsons_7x7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/john-science/mazelib/HEAD/docs/images/wilsons_7x7.png -------------------------------------------------------------------------------- /docs/images/binary_tree_7x7.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/john-science/mazelib/HEAD/docs/images/binary_tree_7x7.gif -------------------------------------------------------------------------------- /docs/images/binary_tree_7x7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/john-science/mazelib/HEAD/docs/images/binary_tree_7x7.png -------------------------------------------------------------------------------- /docs/images/cell_auto_7x7.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/john-science/mazelib/HEAD/docs/images/cell_auto_7x7.gif -------------------------------------------------------------------------------- /docs/images/cell_auto_7x7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/john-science/mazelib/HEAD/docs/images/cell_auto_7x7.png -------------------------------------------------------------------------------- /docs/images/dead_end_steps.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/john-science/mazelib/HEAD/docs/images/dead_end_steps.png -------------------------------------------------------------------------------- /docs/images/huntandkill_7x7.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/john-science/mazelib/HEAD/docs/images/huntandkill_7x7.gif -------------------------------------------------------------------------------- /docs/images/huntandkill_7x7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/john-science/mazelib/HEAD/docs/images/huntandkill_7x7.png -------------------------------------------------------------------------------- /docs/images/perturbation_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/john-science/mazelib/HEAD/docs/images/perturbation_1.png -------------------------------------------------------------------------------- /docs/images/prims_5x5_plain.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/john-science/mazelib/HEAD/docs/images/prims_5x5_plain.png -------------------------------------------------------------------------------- /docs/images/sidewinder_7x7.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/john-science/mazelib/HEAD/docs/images/sidewinder_7x7.gif -------------------------------------------------------------------------------- /docs/images/sidewinder_7x7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/john-science/mazelib/HEAD/docs/images/sidewinder_7x7.png -------------------------------------------------------------------------------- /docs/images/aldous_broder_7x7.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/john-science/mazelib/HEAD/docs/images/aldous_broder_7x7.gif -------------------------------------------------------------------------------- /docs/images/aldous_broder_7x7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/john-science/mazelib/HEAD/docs/images/aldous_broder_7x7.png -------------------------------------------------------------------------------- /docs/images/backtracking_7x7.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/john-science/mazelib/HEAD/docs/images/backtracking_7x7.gif -------------------------------------------------------------------------------- /docs/images/backtracking_7x7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/john-science/mazelib/HEAD/docs/images/backtracking_7x7.png -------------------------------------------------------------------------------- /docs/images/dungeon_rooms_7x7.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/john-science/mazelib/HEAD/docs/images/dungeon_rooms_7x7.gif -------------------------------------------------------------------------------- /docs/images/dungeon_rooms_7x7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/john-science/mazelib/HEAD/docs/images/dungeon_rooms_7x7.png -------------------------------------------------------------------------------- /docs/images/growing_tree_7x7.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/john-science/mazelib/HEAD/docs/images/growing_tree_7x7.gif -------------------------------------------------------------------------------- /docs/images/growing_tree_7x7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/john-science/mazelib/HEAD/docs/images/growing_tree_7x7.png -------------------------------------------------------------------------------- /docs/images/cul_de_sac_filling.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/john-science/mazelib/HEAD/docs/images/cul_de_sac_filling.png -------------------------------------------------------------------------------- /docs/images/dungeon_rooms_11x11.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/john-science/mazelib/HEAD/docs/images/dungeon_rooms_11x11.gif -------------------------------------------------------------------------------- /docs/images/dungeon_rooms_11x11.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/john-science/mazelib/HEAD/docs/images/dungeon_rooms_11x11.png -------------------------------------------------------------------------------- /docs/images/perturbation_sprial.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/john-science/mazelib/HEAD/docs/images/perturbation_sprial.png -------------------------------------------------------------------------------- /docs/images/dungeon_rooms_4x4_plain.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/john-science/mazelib/HEAD/docs/images/dungeon_rooms_4x4_plain.png -------------------------------------------------------------------------------- /mazelib/__init__.py: -------------------------------------------------------------------------------- 1 | """Mazelib: a Python tool for creating and solving mazes.""" 2 | 3 | import importlib.metadata 4 | 5 | __version__ = importlib.metadata.version("mazelib") 6 | 7 | # ruff: noqa: F401 8 | from mazelib.mazelib import Maze 9 | -------------------------------------------------------------------------------- /mazelib/solve/BacktrackingSolver.pxd: -------------------------------------------------------------------------------- 1 | cimport cython 2 | from mazelib.solve.MazeSolveAlgo cimport MazeSolveAlgo 3 | 4 | 5 | cdef class BacktrackingSolver(MazeSolveAlgo): 6 | cdef readonly bint prune 7 | 8 | @cython.locals(solution=list, current=tuple, ns=list, nxt=tuple) 9 | cpdef list _solve(self) 10 | -------------------------------------------------------------------------------- /mazelib/solve/RandomMouse.pxd: -------------------------------------------------------------------------------- 1 | from random import choice, shuffle 2 | cimport cython 3 | from mazelib.solve.MazeSolveAlgo cimport MazeSolveAlgo 4 | 5 | 6 | cdef class RandomMouse(MazeSolveAlgo): 7 | 8 | 9 | @cython.locals(current=tuple, solution=list, ns=list, nxt=tuple) 10 | cpdef list _solve(self) 11 | -------------------------------------------------------------------------------- /.github/workflows/black.yaml: -------------------------------------------------------------------------------- 1 | name: black 2 | 3 | permissions: 4 | contents: read 5 | 6 | on: [push, pull_request] 7 | 8 | jobs: 9 | check-formatting: 10 | runs-on: ubuntu-24.04 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: actions/setup-python@v2 14 | - uses: psf/black@24.10.0 15 | -------------------------------------------------------------------------------- /mazelib/generate/Sidewinder.pxd: -------------------------------------------------------------------------------- 1 | cimport cython 2 | from mazelib.generate.MazeGenAlgo cimport MazeGenAlgo 3 | from numpy cimport ndarray 4 | cimport numpy as np 5 | 6 | 7 | cdef class Sidewinder(MazeGenAlgo): 8 | cdef readonly double skew 9 | 10 | @cython.locals(grid=ndarray, col=cython.int, row=cython.int, run=list, carve_east=bint, 11 | north=tuple) 12 | cpdef ndarray[cython.char, ndim=2] generate(self) 13 | -------------------------------------------------------------------------------- /mazelib/generate/AldousBroder.pxd: -------------------------------------------------------------------------------- 1 | cimport cython 2 | from mazelib.generate.MazeGenAlgo cimport MazeGenAlgo 3 | from numpy cimport ndarray 4 | cimport numpy as np 5 | 6 | 7 | cdef class AldousBroder(MazeGenAlgo): 8 | 9 | @cython.locals(grid=ndarray, crow=cython.uint, ccol=cython.uint, nrow=cython.uint, ncol=cython.uint, 10 | num_visited=cython.uint, neighbors=list) 11 | cpdef ndarray[cython.char, ndim=2] generate(self) 12 | -------------------------------------------------------------------------------- /mazelib/generate/BacktrackingGenerator.pxd: -------------------------------------------------------------------------------- 1 | cimport cython 2 | from mazelib.generate.MazeGenAlgo cimport MazeGenAlgo 3 | from numpy cimport ndarray 4 | cimport numpy as np 5 | 6 | 7 | cdef class BacktrackingGenerator(MazeGenAlgo): 8 | 9 | @cython.locals(grid=ndarray, crow=cython.uint, ccol=cython.uint, nrow=cython.uint, ncol=cython.uint, track=list, 10 | neighbors=list) 11 | cpdef ndarray[cython.char, ndim=2] generate(self) 12 | -------------------------------------------------------------------------------- /mazelib/generate/MazeGenAlgo.pxd: -------------------------------------------------------------------------------- 1 | cimport cython 2 | from numpy cimport ndarray 3 | cimport numpy as np 4 | 5 | 6 | cdef class MazeGenAlgo: 7 | cdef public int h 8 | cdef public int w 9 | cdef public int H 10 | cdef public int W 11 | 12 | cpdef ndarray[cython.char, ndim=2] generate(self) 13 | 14 | @cython.locals(ns=list) 15 | cdef list _find_neighbors(self, int r, int c, ndarray[cython.char, ndim=2] grid, bint is_wall=*) 16 | -------------------------------------------------------------------------------- /mazelib/transmute/CuldeSacFiller.pxd: -------------------------------------------------------------------------------- 1 | cimport cython 2 | from mazelib.transmute.MazeTransmuteAlgo cimport MazeTransmuteAlgo 3 | 4 | 5 | cdef class CuldeSacFiller(MazeTransmuteAlgo): 6 | 7 | 8 | @cython.locals(r=cython.int, c=cython.int, ns=list, end1=tuple, end2=tuple) 9 | cpdef void _transmute(self) 10 | 11 | 12 | @cython.locals(first=tuple, previous=tuple, current=tuple, ns=list) 13 | cdef inline tuple _find_next_intersection(self, list path_start) 14 | -------------------------------------------------------------------------------- /mazelib/generate/Prims.pxd: -------------------------------------------------------------------------------- 1 | cimport cython 2 | from mazelib.generate.MazeGenAlgo cimport MazeGenAlgo 3 | from numpy cimport ndarray 4 | cimport numpy as np 5 | 6 | 7 | cdef class Prims(MazeGenAlgo): 8 | 9 | @cython.locals(grid=ndarray, current_row=cython.int, current_col=cython.int, visited=cython.int, 10 | nearest_n0=cython.int, nearest_n1=cython.int, unvisited=list, neighbors=list, nn=cython.int) 11 | cpdef ndarray[cython.char, ndim=2] generate(self) 12 | -------------------------------------------------------------------------------- /mazelib/generate/GrowingTree.pxd: -------------------------------------------------------------------------------- 1 | cimport cython 2 | from mazelib.generate.MazeGenAlgo cimport MazeGenAlgo 3 | from numpy cimport ndarray 4 | cimport numpy as np 5 | 6 | 7 | cdef class GrowingTree(MazeGenAlgo): 8 | cdef readonly double backtrack_chance 9 | 10 | @cython.locals(grid=ndarray, current_row=cython.int, current_col=cython.int, nn_row=cython.int, nn_col=cython.int, 11 | active=list, next_neighbors=list) 12 | cpdef ndarray[cython.char, ndim=2] generate(self) 13 | -------------------------------------------------------------------------------- /mazelib/generate/Kruskal.pxd: -------------------------------------------------------------------------------- 1 | cimport cython 2 | from mazelib.generate.MazeGenAlgo cimport MazeGenAlgo 3 | from numpy cimport ndarray 4 | cimport numpy as np 5 | 6 | 7 | cdef class Kruskal(MazeGenAlgo): 8 | 9 | @cython.locals(grid=ndarray, row=cython.int, col=cython.int, forest=list, edges=list, new_tree=list, 10 | ce_row=cython.int, ce_col=cython.int, tree1=cython.int, tree2=cython.int, 11 | temp1=list, temp2=list) 12 | cpdef ndarray[cython.char, ndim=2] generate(self) 13 | -------------------------------------------------------------------------------- /mazelib/generate/CellularAutomaton.pxd: -------------------------------------------------------------------------------- 1 | cimport cython 2 | from mazelib.generate.MazeGenAlgo cimport MazeGenAlgo 3 | from numpy cimport ndarray 4 | cimport numpy as np 5 | 6 | 7 | cdef class CellularAutomaton(MazeGenAlgo): 8 | cdef readonly cython.double complexity 9 | cdef readonly cython.double density 10 | 11 | @cython.locals(grid=ndarray, i=cython.int, j=cython.int, x=cython.int, y=cython.int, 12 | neighbors=list, r=cython.int, c=cython.int) 13 | cpdef ndarray[cython.char, ndim=2] generate(self) 14 | -------------------------------------------------------------------------------- /.github/workflows/ruff.yaml: -------------------------------------------------------------------------------- 1 | name: Ruff Linting 2 | 3 | permissions: 4 | contents: read 5 | 6 | on: [push, pull_request] 7 | 8 | jobs: 9 | build: 10 | 11 | runs-on: ubuntu-24.04 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Setup Python 16 | uses: actions/setup-python@v2 17 | with: 18 | python-version: '3.13' 19 | - name: Update package index 20 | run: sudo apt-get update 21 | - name: Run Linter 22 | run: | 23 | pip install -e .[dev] 24 | ruff check . -------------------------------------------------------------------------------- /mazelib/solve/Tremaux.pxd: -------------------------------------------------------------------------------- 1 | cimport cython 2 | from mazelib.solve.MazeSolveAlgo cimport MazeSolveAlgo 3 | 4 | 5 | cdef class Tremaux(MazeSolveAlgo): 6 | cdef readonly dict visited_cells 7 | 8 | 9 | @cython.locals(solution=list, ns=list, current=tuple, nxt=tuple) 10 | cpdef list _solve(self) 11 | 12 | 13 | cdef inline void _visit(self, tuple cell) 14 | 15 | 16 | cdef inline cython.int _get_visit_count(self, tuple cell) 17 | 18 | 19 | @cython.locals(visit_counts=dict, neighbor=tuple, visit_count=int) 20 | cdef inline tuple _what_next(self, list ns, list solution) 21 | -------------------------------------------------------------------------------- /mazelib/solve/ShortestPaths.pxd: -------------------------------------------------------------------------------- 1 | cimport cython 2 | from mazelib.solve.MazeSolveAlgo cimport MazeSolveAlgo 3 | 4 | 5 | cdef class ShortestPaths(MazeSolveAlgo): 6 | cdef readonly bint start_edge 7 | cdef readonly bint end_edge 8 | 9 | 10 | @cython.locals(start=tuple, start_posis=list, solutions=list, s=cython.int, num_unfinished=cython.int, 11 | ns=list, j=cython.int, nxt=list, sp=tuple) 12 | cpdef list _solve(self) 13 | 14 | 15 | @cython.locals(new_solutions=list, sol=list, new_sol=list, last=tuple) 16 | cdef inline list _clean_up(self, list solutions) 17 | 18 | 19 | @cython.locals(s=tuple) 20 | cdef inline list _remove_duplicate_sols(self, list sols) 21 | -------------------------------------------------------------------------------- /mazelib/solve/ShortestPath.pxd: -------------------------------------------------------------------------------- 1 | cimport cython 2 | from mazelib.solve.MazeSolveAlgo cimport MazeSolveAlgo 3 | 4 | 5 | cdef class ShortestPath(MazeSolveAlgo): 6 | cdef readonly bint start_edge 7 | cdef readonly bint end_edge 8 | 9 | 10 | @cython.locals(start=tuple, start_posis=list, solutions=list, s=tuple, num_unfinished=cython.int, 11 | ns=list, s=cython.int, sp=tuple, j=cython.int, nxt=list) 12 | cpdef list _solve(self) 13 | 14 | 15 | @cython.locals(new_solutions=list, sol=list, new_sol=list, last=tuple) 16 | cdef inline list _clean_up(self, list solutions) 17 | 18 | 19 | @cython.locals(s=tuple) 20 | cdef inline list _remove_duplicate_sols(self, list sols) 21 | -------------------------------------------------------------------------------- /mazelib/generate/BinaryTree.pxd: -------------------------------------------------------------------------------- 1 | cimport cython 2 | from mazelib.generate.MazeGenAlgo cimport MazeGenAlgo 3 | from numpy cimport ndarray 4 | cimport numpy as np 5 | 6 | 7 | cdef class BinaryTree(MazeGenAlgo): 8 | cdef readonly list skew 9 | 10 | @cython.locals(grid=ndarray, row=cython.uint, col=cython.uint, neighbor_row=cython.uint, neighbor_col=cython.uint) 11 | cpdef ndarray[cython.char, ndim=2] generate(self) 12 | 13 | @cython.locals(neighbors=list, neighbor_row=cython.uint, neighbor_col=cython.uint, b_row=cython.int, 14 | b_col=cython.int, current_row=cython.uint, current_col=cython.uint) 15 | cdef inline tuple _find_neighbor(self, cython.uint current_row, cython.uint current_col) 16 | -------------------------------------------------------------------------------- /mazelib/generate/Division.pxd: -------------------------------------------------------------------------------- 1 | cimport cython 2 | from mazelib.generate.MazeGenAlgo cimport MazeGenAlgo 3 | from numpy cimport ndarray 4 | cimport numpy as np 5 | 6 | # CONSTANTS 7 | cdef cython.int VERTICAL = 0 8 | cdef cython.int HORIZONTAL = 1 9 | 10 | 11 | 12 | cdef class Division(MazeGenAlgo): 13 | 14 | @cython.locals(grid=ndarray, region_stack=list, current_region=tuple, region_stack=list, min_y=cython.int, 15 | max_y=cython.int, min_x=cython.int, min_y=cython.int, height=cython.int, width=cython.int, 16 | cut_direction=cython.int, cut_length=cython.int, cut_posi=cython.int, door_posi=cython.int, 17 | row=cython.int, col=cython.int) 18 | cpdef ndarray[cython.char, ndim=2] generate(self) 19 | -------------------------------------------------------------------------------- /mazelib/transmute/MazeTransmuteAlgo.pxd: -------------------------------------------------------------------------------- 1 | cimport cython 2 | from numpy cimport ndarray 3 | 4 | 5 | cdef class MazeTransmuteAlgo: 6 | cdef public ndarray grid 7 | cdef public tuple start 8 | cdef public tuple end 9 | 10 | 11 | cpdef void transmute(self, ndarray[cython.char, ndim=2] grid, tuple start, tuple end) 12 | 13 | 14 | cpdef void _transmute(self) 15 | 16 | 17 | @cython.locals(r=cython.int, c=cython.int, ns=list) 18 | cdef inline _find_unblocked_neighbors(self, tuple posi) 19 | 20 | 21 | @cython.locals(ns=list) 22 | cdef inline list _find_neighbors(self, int r, int c, bint is_wall=*) 23 | 24 | 25 | cdef inline bint _within_one(self, tuple cell, tuple desire) 26 | 27 | 28 | cdef inline tuple _midpoint(self, tuple a, tuple b) 29 | -------------------------------------------------------------------------------- /mazelib/solve/Collision.pxd: -------------------------------------------------------------------------------- 1 | cimport cython 2 | from mazelib.solve.MazeSolveAlgo cimport MazeSolveAlgo 3 | 4 | cdef tuple END = (-999, -9) 5 | cdef tuple DEAD_END = (-9, -999) 6 | 7 | 8 | cdef class Collision(MazeSolveAlgo): 9 | 10 | @cython.locals(start=tuple, paths=list, temp_paths=list, diff=list) 11 | cpdef list _solve(self) 12 | 13 | @cython.locals(paths=list, temp_paths=list) 14 | cdef inline list _flood_maze(self, tuple start) 15 | 16 | @cython.locals(temp_paths=list, step_made=bint, ns=list, mid=tuple, neighbor=tuple) 17 | cdef inline list _one_time_step(self, list paths) 18 | 19 | @cython.locals(N=cython.uint, i =cython.uint, j=cython.uint, row=cython.int, col=cython.int) 20 | cdef inline list _fix_collisions(self, list paths) 21 | 22 | @cython.locals(p=list) 23 | cdef inline list _fix_entrances(self, list paths) 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # Cython and C extensions 6 | *.so 7 | *.c 8 | *.h 9 | *.html 10 | 11 | # Distribution / packaging 12 | .Python 13 | env/ 14 | bin/ 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | 29 | # Installer logs 30 | pip-log.txt 31 | pip-delete-this-directory.txt 32 | 33 | # Unit test / coverage reports 34 | htmlcov/ 35 | .tox/ 36 | .coverage 37 | cover/ 38 | .cache 39 | .idea/ 40 | nosetests.xml 41 | coverage.xml 42 | 43 | # Translations 44 | *.mo 45 | 46 | # Mr Developer 47 | .mr.developer.cfg 48 | .project 49 | .pydevproject 50 | 51 | # Rope 52 | .ropeproject 53 | 54 | # Django stuff: 55 | *.log 56 | *.pot 57 | 58 | # Sphinx documentation 59 | docs/_build/ 60 | 61 | -------------------------------------------------------------------------------- /mazelib/transmute/DeadEndFiller.pxd: -------------------------------------------------------------------------------- 1 | cimport cython 2 | from mazelib.transmute.MazeTransmuteAlgo cimport MazeTransmuteAlgo 3 | 4 | 5 | cdef class DeadEndFiller(MazeTransmuteAlgo): 6 | cdef public cython.int iterations 7 | 8 | 9 | @cython.locals(r=cython.int, c=cython.int, found=cython.bint, i=cython.int, 10 | start_save=cython.char, end_save=cython.char) 11 | cpdef void _transmute(self) 12 | 13 | 14 | @cython.locals(dead_end=tuple, ns=list, found=cython.int) 15 | cdef inline cython.int _fill_dead_ends(self) 16 | 17 | 18 | @cython.locals(r=cython.int, c=cython.int) 19 | cdef inline void _fill_dead_end(self, tuple dead_end) 20 | 21 | 22 | @cython.locals(r=cython.int, c=cython.int) 23 | cdef inline tuple _find_dead_end(self) 24 | 25 | 26 | @cython.locals(ns=list) 27 | cdef inline bint _is_dead_end(self, tuple cell) 28 | -------------------------------------------------------------------------------- /.github/workflows/coverage.yaml: -------------------------------------------------------------------------------- 1 | name: code coverage 2 | 3 | permissions: 4 | contents: read 5 | 6 | on: 7 | push: 8 | branches: 9 | - main 10 | pull_request: 11 | branches: 12 | - main 13 | 14 | jobs: 15 | build: 16 | if: github.repository == 'john-science/mazelib' 17 | 18 | runs-on: ubuntu-24.04 19 | 20 | steps: 21 | - uses: actions/checkout@v2 22 | - name: Setup Python 23 | uses: actions/setup-python@v2 24 | with: 25 | python-version: "3.13" 26 | - name: Update package index 27 | run: sudo apt-get update 28 | - name: Install PIP Packages 29 | run: | 30 | pip install -e . 31 | pip install pytest 32 | pip install codecov 33 | - name: Run Coverage Tests 34 | run: | 35 | coverage run -m unittest discover 36 | codecov 37 | env: 38 | PYTHONHASHSEED: 0 39 | -------------------------------------------------------------------------------- /.github/workflows/unittests.yaml: -------------------------------------------------------------------------------- 1 | name: unit tests 2 | 3 | permissions: 4 | contents: read 5 | 6 | on: 7 | push: 8 | paths-ignore: 9 | - 'doc/**' 10 | pull_request: 11 | paths-ignore: 12 | - 'doc/**' 13 | 14 | jobs: 15 | build: 16 | 17 | runs-on: ${{ matrix.os }} 18 | strategy: 19 | matrix: 20 | # BEHOLD, our test grid! 21 | os: [ubuntu-24.04, windows-2025, macos-15] 22 | python: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] 23 | 24 | steps: 25 | - uses: actions/checkout@v4 26 | - name: Setup Python ${{ matrix.python }} 27 | uses: actions/setup-python@v5 28 | with: 29 | python-version: ${{ matrix.python }} 30 | - name: Install PIP Packages 31 | run: | 32 | pip install -e . 33 | pip install pytest 34 | - name: Run tests 35 | run: pytest test/ 36 | env: 37 | PYTHONHASHSEED: 0 38 | -------------------------------------------------------------------------------- /mazelib/generate/TrivialMaze.pxd: -------------------------------------------------------------------------------- 1 | cimport cython 2 | from mazelib.generate.MazeGenAlgo cimport MazeGenAlgo 3 | from numpy cimport ndarray 4 | cimport numpy as np 5 | 6 | cdef public cython.int SERPENTINE = 1 7 | cdef public cython.int SPIRAL = 2 8 | 9 | 10 | cdef class TrivialMaze(MazeGenAlgo): 11 | cdef readonly cython.int maze_type 12 | 13 | @cython.locals(grid=ndarray) 14 | cpdef ndarray[cython.char, ndim=2] generate(self) 15 | 16 | @cython.locals(vertical_skew=cython.int, row=cython.int, col=cython.int, height=cython.int, width=cython.int) 17 | cdef inline ndarray[cython.char, ndim=2] _generate_serpentine_maze(self, ndarray[cython.char, ndim=2] grid) 18 | 19 | @cython.locals(clockwise=cython.int, directions=list, current=tuple, next_dir=cython.int, next_cell=tuple, 20 | ns=list) 21 | cdef inline ndarray[cython.char, ndim=2] _generate_spiral_maze(self, ndarray[cython.char, ndim=2] grid) 22 | 23 | cdef inline tuple _midpoint(self, tuple a, tuple b) 24 | 25 | cdef inline tuple _move(self, tuple start, tuple direction) 26 | -------------------------------------------------------------------------------- /mazelib/generate/HuntAndKill.pxd: -------------------------------------------------------------------------------- 1 | cimport cython 2 | from mazelib.generate.MazeGenAlgo cimport MazeGenAlgo 3 | from numpy cimport ndarray 4 | cimport numpy as np 5 | 6 | cdef public cython.int RANDOM = 1 7 | cdef public cython.int SERPENTINE = 2 8 | 9 | 10 | cdef class HuntAndKill(MazeGenAlgo): 11 | cdef readonly cython.int ho 12 | 13 | @cython.locals(grid=ndarray, num_trails=cython.int, current_row=cython.int, current_col=cython.int) 14 | cpdef ndarray[cython.char, ndim=2] generate(self) 15 | 16 | @cython.locals(this_row=cython.int, this_col=cython.int, unvisited_neighbors=list, neighbor=tuple) 17 | cdef inline void _walk(self, ndarray[cython.char, ndim=2] grid, cython.int row, cython.int col) 18 | 19 | cdef inline tuple _hunt(self, ndarray[cython.char, ndim=2] grid, cython.int count) 20 | 21 | cdef inline tuple _hunt_random(self, ndarray[cython.char, ndim=2] grid, cython.int count) 22 | 23 | @cython.locals(row=cython.int, col=cython.int, found=bint) 24 | cdef inline tuple _hunt_serpentine(self, ndarray[cython.char, ndim=2] grid, cython.int count) 25 | -------------------------------------------------------------------------------- /.github/workflows/wheels.yaml: -------------------------------------------------------------------------------- 1 | name: build wheels 2 | 3 | permissions: 4 | contents: read 5 | 6 | on: 7 | push: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | build: 13 | if: github.repository == 'john-science/mazelib' 14 | 15 | runs-on: ubuntu-24.04 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: Setup Python 20 | uses: actions/setup-python@v2 21 | with: 22 | python-version: "3.13" 23 | - name: Update package index 24 | run: sudo apt-get update 25 | - name: Install PIP Packages 26 | run: | 27 | pip install -U pip 28 | pip install -e . 29 | - name: Build Wheels 30 | run: | 31 | pip install -U wheel 32 | mkdir dist 33 | pip wheel . -w dist/ 34 | rm dist/Cython*.whl 35 | rm dist/numpy*.whl 36 | - name: Archive PIP wheel artifacts 37 | uses: actions/upload-artifact@v4 38 | with: 39 | name: mazelib-wheels 40 | path: | 41 | dist/mazelib*.whl 42 | retention-days: 5 43 | -------------------------------------------------------------------------------- /.github/workflows/stale.yaml: -------------------------------------------------------------------------------- 1 | # This workflow warns and then closes PRs that have had no activity for a specified amount of time. 2 | # 3 | # You can adjust the behavior by modifying this file. 4 | # For more information, see: https://github.com/actions/stale 5 | name: Mark Stale PRs 6 | 7 | on: 8 | schedule: 9 | # once a day at 3:14 AM 10 | - cron: '14 3 * * *' 11 | 12 | permissions: 13 | pull-requests: write 14 | 15 | jobs: 16 | stale: 17 | # This workflow is not designed to make sense on forks 18 | if: github.repository == 'john-science/mazelib' 19 | runs-on: ubuntu-24.04 20 | steps: 21 | - uses: actions/stale@v8 22 | with: 23 | repo-token: ${{ secrets.GITHUB_TOKEN }} 24 | stale-pr-message: "This pull request has been automatically marked as stale because it has not had any activity in the last 100 days. It will be closed in 7 days if no further activity occurs. Thank you for your contributions." 25 | stale-pr-label: "stale" 26 | days-before-pr-stale: 100 27 | days-before-pr-close: 7 28 | days-before-issue-stale: -1 29 | operations-per-run: 100 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [2014] [John Stilley] 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. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | ########################################## 2 | # Just a handy set of dev shortcuts # 3 | ########################################## 4 | .PHONY: all clean uninstall install lint test benchmark wheel twine 5 | 6 | all: 7 | @grep -Ee '^[a-z].*:' Makefile | cut -d: -f1 | grep -vF all 8 | 9 | clean: 10 | git clean -dfxq --exclude=*.py --exclude=*.pxd 11 | 12 | uninstall: clean 13 | @echo pip uninstalling mazelib 14 | $(shell pip uninstall -y mazelib >/dev/null 2>/dev/null) 15 | $(shell pip uninstall -y mazelib >/dev/null 2>/dev/null) 16 | $(shell pip uninstall -y mazelib >/dev/null 2>/dev/null) 17 | 18 | install: uninstall 19 | pip install -U pip 20 | pip install . 21 | 22 | lint: 23 | black . 24 | 25 | test: 26 | python test/test_maze.py 27 | python test/test_generators.py 28 | python test/test_solvers.py 29 | python test/test_transmuters.py 30 | 31 | benchmark: 32 | python benchmarks/benchmarks.py 33 | 34 | wheel: 35 | pip install -U pip 36 | pip install -U wheel 37 | mkdir -f dist 38 | pip wheel . -w dist/ 39 | rm dist/Cython*.whl 40 | rm dist/numpy*.whl 41 | auditwheel repair dist/mazelib*.whl -w . 42 | 43 | twine: wheel 44 | twine upload --repository-url https://upload.pypi.org/legacy/ dist/* 45 | -------------------------------------------------------------------------------- /mazelib/solve/Chain.pxd: -------------------------------------------------------------------------------- 1 | cimport cython 2 | from mazelib.solve.MazeSolveAlgo cimport MazeSolveAlgo 3 | 4 | 5 | cdef class Chain(MazeSolveAlgo): 6 | cdef readonly list directions 7 | 8 | 9 | @cython.locals(guiding_line=list, current=cython.int, solution=list, len_guiding_line=cython.int, 10 | current=cython.int, success=bint) 11 | cpdef list _solve(self) 12 | 13 | 14 | @cython.locals(ns=list, robot_path=list, robot_paths=list, n=tuple, j=cython.int, path=list, 15 | last_diff=tuple, last_dir=int, shortest_robot_path=list, min_len=cython.int) 16 | cdef inline cython.int _send_out_robots(self, list solution, list guiding_line, cython.int i) 17 | 18 | 19 | @cython.locals(path=list, ns=list, nxt=tuple) 20 | cdef inline list _backtracking_solve(self, list solution, tuple goal) 21 | 22 | 23 | @cython.locals(r=cython.int, c=cython.int, next=tuple, rdiff=cython.int, cdiff=cython.int) 24 | cdef inline bint _try_direct_move(self, list solution, list guiding_line, cython.int i) 25 | 26 | 27 | @cython.locals(r1=cython.int, c1=cython.int, r2=cython.int, c2=cython.int, path=list, current=tuple, 28 | rdiff=cython.int, cdiff=cython.int) 29 | cdef inline list _draw_guiding_line(self) -------------------------------------------------------------------------------- /mazelib/transmute/Perturbation.pxd: -------------------------------------------------------------------------------- 1 | cimport cython 2 | from mazelib.transmute.MazeTransmuteAlgo cimport MazeTransmuteAlgo 3 | from numpy cimport ndarray 4 | cimport numpy as np 5 | 6 | 7 | cdef class Perturbation(MazeTransmuteAlgo): 8 | cdef readonly cython.uint repeat 9 | cdef readonly cython.uint new_walls 10 | 11 | 12 | @cython.locals(grid=ndarray, i=cython.int, j=cython.int) 13 | cpdef void _transmute(self) 14 | 15 | 16 | @cython.locals(limit=cython.int, tries=cython.int, found=bint, row=cython.int, col=cython.int) 17 | cdef inline void _add_a_random_wall(self) 18 | 19 | 20 | @cython.locals(passages=list) 21 | cdef inline void _reconnect_maze(self) 22 | 23 | 24 | @cython.locals(passages=list, found=bint, r=cython.int, c=cython.int, ns=list, current=set, 25 | i=cython.int, passage=set, intersect=set) 26 | cdef inline list _find_all_passages(self) 27 | 28 | 29 | @cython.locals(found=bint, cell=tuple, neighbors=list, passage=set, intersect=list, c=tuple, 30 | cell=tuple, mid=tuple) 31 | cdef inline ndarray[cython.char, ndim=2] _fix_disjoint_passages(self, list disjoint_passages) 32 | 33 | 34 | @cython.locals(i=cython.int, j=cython.int, intersect=set, l=set) 35 | cpdef list _join_intersecting_sets(self, list list_of_sets) 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mazelib 2 | [](https://github.com/john-science/mazelib/blob/main/.github/workflows/unittests.yaml) 3 | [](https://codecov.io/gh/john-science/mazelib) 4 | 5 | #### A Python API for creating and solving mazes. 6 | 7 | ## [The mazelib API](https://github.com/john-science/mazelib/blob/main/docs/API.md) 8 | 9 | A quick introduction in how to use this library. 10 | 11 | 12 | ## [Display Examples](https://github.com/john-science/mazelib/blob/main/docs/EXAMPLES.md) 13 | 14 | Examples of how to generate and solve some unique, tailored mazes. Also, examples of how to plot your results. 15 | 16 | 17 | ## Maze Algorithms 18 | 19 | The heart of this library is the huge collection of algorithms available to create and solve mazes. The better you understand these, the more you can do with this library. 20 | 21 | ### [Maze-Generating Algorithms](https://github.com/john-science/mazelib/blob/main/docs/MAZE_GEN_ALGOS.md) 22 | 23 | ### [Maze-Transmuting Algorithms](https://github.com/john-science/mazelib/blob/main/docs/MAZE_TRANSMUTE_ALGOS.md) 24 | 25 | ### [Maze-Solving Algorithms](https://github.com/john-science/mazelib/blob/main/docs/MAZE_SOLVE_ALGOS.md) 26 | -------------------------------------------------------------------------------- /mazelib/solve/BacktrackingSolver.py: -------------------------------------------------------------------------------- 1 | from random import choice 2 | 3 | # If the code is not Cython-compiled, we need to add some imports. 4 | from cython import compiled 5 | 6 | if not compiled: 7 | from mazelib.solve.MazeSolveAlgo import MazeSolveAlgo 8 | 9 | 10 | class BacktrackingSolver(MazeSolveAlgo): 11 | """ 12 | The Backtracking Solver maze solving algorithm. 13 | 14 | 1. Pick a random direction and follow it 15 | 2. Backtrack if and only if you hit a dead end. 16 | """ 17 | 18 | def _solve(self): 19 | solution = [] 20 | 21 | # a first move has to be made 22 | current = self.start 23 | if self._on_edge(self.start): 24 | current = self._push_edge(self.start) 25 | solution.append(current) 26 | 27 | # pick a random neighbor and travel to it, until you're at the end 28 | while not self._within_one(solution[-1], self.end): 29 | ns = self._find_unblocked_neighbors(solution[-1]) 30 | 31 | # do no go where you've just been 32 | if len(ns) > 1 and len(solution) > 2: 33 | if solution[-3] in ns: 34 | ns.remove(solution[-3]) 35 | 36 | nxt = choice(ns) 37 | solution.append(self._midpoint(solution[-1], nxt)) 38 | solution.append(nxt) 39 | 40 | return [solution] 41 | -------------------------------------------------------------------------------- /mazelib/generate/Ellers.pxd: -------------------------------------------------------------------------------- 1 | cimport cython 2 | from mazelib.generate.MazeGenAlgo cimport MazeGenAlgo 3 | from numpy cimport ndarray 4 | cimport numpy as np 5 | 6 | 7 | cdef class Ellers(MazeGenAlgo): 8 | cdef readonly double xskew 9 | cdef readonly double yskew 10 | 11 | @cython.locals(grid=ndarray, sets=ndarray, max_set_number=cython.int, r=cython.int) 12 | cpdef ndarray[cython.char, ndim=2] generate(self) 13 | 14 | @cython.locals(c=cython.int, max_set_number=cython.int) 15 | cdef inline cython.int _init_row(self, ndarray[cython.char, ndim=2] sets, cython.int row, cython.int max_set_number) 16 | 17 | @cython.locals(c=cython.int) 18 | cdef inline void _merge_one_row(self, ndarray[cython.char, ndim=2] sets, cython.int r) 19 | 20 | @cython.locals(set_counts=dict, c=cython.int, s=cython.int) 21 | cdef inline void _merge_down_a_row(self, ndarray[cython.char, ndim=2] sets, cython.int start_row) 22 | 23 | @cython.locals(c=cython.int, r=cython.int) 24 | cdef inline void _merge_sets(self, ndarray[cython.char, ndim=2] sets, cython.int from_set, cython.int to_set, cython.int max_row=*) 25 | 26 | @cython.locals(c=cython.int, r=cython.int) 27 | cdef inline void _process_last_row(self, ndarray[cython.char, ndim=2] sets) 28 | 29 | @cython.locals(grid=ndarray, c=cython.int, r=cython.int) 30 | cdef inline ndarray[cython.char, ndim=2] _create_grid_from_sets(self, ndarray[cython.char, ndim=2] sets) 31 | -------------------------------------------------------------------------------- /mazelib/solve/MazeSolveAlgo.pxd: -------------------------------------------------------------------------------- 1 | cimport cython 2 | from numpy cimport ndarray 3 | 4 | 5 | cdef class MazeSolveAlgo: 6 | cdef public ndarray grid 7 | cdef public tuple start 8 | cdef public tuple end 9 | 10 | 11 | cpdef list solve(self, ndarray[cython.char, ndim=2] grid, tuple start, tuple end) 12 | 13 | 14 | cdef inline int _solve_preprocessor(self, ndarray[cython.char, ndim=2] grid, tuple start, 15 | tuple end) except -1 16 | 17 | 18 | cpdef list _solve(self) 19 | 20 | 21 | @cython.locals(r=cython.int, c=cython.int, ns=list) 22 | cdef inline list _find_unblocked_neighbors(self, tuple posi) 23 | 24 | 25 | cdef inline tuple _midpoint(self, tuple a, tuple b) 26 | 27 | 28 | cdef inline tuple _move(self, tuple start, tuple direction) 29 | 30 | 31 | @cython.locals(r=cython.int, c=cython.int) 32 | cdef inline bint _on_edge(self, tuple cell) 33 | 34 | 35 | @cython.locals(r=cython.int, c=cython.int) 36 | cdef inline tuple _push_edge(self, tuple cell) 37 | 38 | 39 | cdef inline bint _within_one(self, tuple cell, tuple desire) 40 | 41 | 42 | @cython.locals(found=bint, attempt=cython.int, first_i=cython.int, last_i=cython.int, i=cython.int, 43 | max_attempt=cython.int, first=tuple) 44 | cpdef list _prune_solution(self, list solution) 45 | 46 | 47 | @cython.locals(s=list) 48 | cpdef list prune_solutions(self, list solutions) 49 | -------------------------------------------------------------------------------- /mazelib/solve/RandomMouse.py: -------------------------------------------------------------------------------- 1 | from random import choice 2 | 3 | # If the code is not Cython-compiled, we need to add some imports. 4 | from cython import compiled 5 | 6 | if not compiled: 7 | from mazelib.solve.MazeSolveAlgo import MazeSolveAlgo 8 | 9 | 10 | class RandomMouse(MazeSolveAlgo): 11 | """ 12 | The Random Mouse maze solving algorithm. 13 | 14 | This mouse just randomly wanders around the maze until it finds the cheese. 15 | """ 16 | 17 | def _solve(self): 18 | """Solve a maze as stupidly as possible: just wander randomly until you find the end. 19 | 20 | This should be basically optimally slow and should have just obsurdly long solutions, 21 | with lots of double backs. 22 | 23 | Returns 24 | ------- 25 | list: solution to the maze 26 | """ 27 | solution = [] 28 | 29 | # a first move has to be made 30 | current = self.start 31 | if self._on_edge(self.start): 32 | current = self._push_edge(self.start) 33 | solution.append(current) 34 | 35 | # pick a random neighbor and travel to it, until you're at the end 36 | while not self._within_one(solution[-1], self.end): 37 | ns = self._find_unblocked_neighbors(solution[-1]) 38 | 39 | nxt = choice(ns) 40 | solution.append(self._midpoint(solution[-1], nxt)) 41 | solution.append(nxt) 42 | 43 | return [solution] 44 | -------------------------------------------------------------------------------- /mazelib/generate/Wilsons.pxd: -------------------------------------------------------------------------------- 1 | cimport cython 2 | from mazelib.generate.MazeGenAlgo cimport MazeGenAlgo 3 | from numpy cimport ndarray 4 | cimport numpy as np 5 | 6 | 7 | cdef public cython.int RANDOM = 1 8 | cdef public cython.int SERPENTINE = 2 9 | 10 | 11 | cdef class Wilsons(MazeGenAlgo): 12 | cdef readonly cython.int _hunt_order 13 | 14 | 15 | @cython.locals(grid=ndarray, row=cython.int, col=cython.int, num_visited=cython.int, 16 | walk=dict) 17 | cpdef ndarray[cython.char, ndim=2] generate(self) 18 | 19 | 20 | cdef inline tuple _hunt(self, ndarray[cython.char, ndim=2] grid, cython.int count) 21 | 22 | 23 | cdef inline tuple _hunt_random(self, ndarray[cython.char, ndim=2] grid, cython.int count) 24 | 25 | 26 | @cython.locals(cell=tuple, found=bint) 27 | cdef inline tuple _hunt_serpentine(self, ndarray[cython.char, ndim=2] grid, cython.int count) 28 | 29 | 30 | @cython.locals(direction=tuple, walk=dict, current=tuple) 31 | cdef inline dict _generate_random_walk(self, ndarray[cython.char, ndim=2] grid, tuple start) 32 | 33 | 34 | @cython.locals(r=cython.int, c=cython.int, options=list, direction=cython.int) 35 | cdef inline tuple _random_dir(self, tuple current) 36 | 37 | 38 | cdef inline tuple _move(self, tuple start, tuple direction) 39 | 40 | 41 | @cython.locals(visits=cython.int, current=tuple, next1=tuple) 42 | cdef inline cython.int _solve_random_walk(self, ndarray[cython.char, ndim=2] grid, dict walk, tuple start) 43 | -------------------------------------------------------------------------------- /mazelib/generate/BacktrackingGenerator.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from random import randrange 3 | 4 | # If the code is not Cython-compiled, we need to add some imports. 5 | from cython import compiled 6 | 7 | if not compiled: 8 | from mazelib.generate.MazeGenAlgo import MazeGenAlgo 9 | 10 | 11 | class BacktrackingGenerator(MazeGenAlgo): 12 | """ 13 | The Backtracking maze-generating algorithm. 14 | 15 | 1. Randomly choose a starting cell. 16 | 2. Randomly choose a wall at the current cell and open a passage through to any random adjacent 17 | cell, that has not been visited yet. This is now the current cell. 18 | 3. If all adjacent cells have been visited, back up to the previous and repeat step 2. 19 | 4. Stop when the algorithm has backed all the way up to the starting cell. 20 | """ 21 | 22 | def __init__(self, w, h): 23 | super(BacktrackingGenerator, self).__init__(w, h) 24 | 25 | def generate(self): 26 | """Highest-level method that implements the maze-generating algorithm. 27 | 28 | Returns 29 | ------- 30 | np.array: returned matrix 31 | """ 32 | # create empty grid, with walls 33 | grid = np.empty((self.H, self.W), dtype=np.int8) 34 | grid.fill(1) 35 | 36 | crow = randrange(1, self.H, 2) 37 | ccol = randrange(1, self.W, 2) 38 | track = [(crow, ccol)] 39 | grid[crow][ccol] = 0 40 | 41 | while track: 42 | (crow, ccol) = track[-1] 43 | neighbors = self._find_neighbors(crow, ccol, grid, True) 44 | 45 | if len(neighbors) == 0: 46 | track = track[:-1] 47 | else: 48 | nrow, ncol = neighbors[0] 49 | grid[nrow][ncol] = 0 50 | grid[(nrow + crow) // 2][(ncol + ccol) // 2] = 0 51 | 52 | track += [(nrow, ncol)] 53 | 54 | return grid 55 | -------------------------------------------------------------------------------- /mazelib/generate/MazeGenAlgo.py: -------------------------------------------------------------------------------- 1 | import abc 2 | 3 | # ruff: noqa: F401 4 | import numpy as np 5 | from numpy.random import shuffle 6 | 7 | 8 | class MazeGenAlgo: 9 | __metaclass__ = abc.ABCMeta 10 | 11 | def __init__(self, h, w): 12 | """Maze Generator Algorithm constructor. 13 | 14 | Attributes 15 | ---------- 16 | h (int): height of maze, in number of hallways 17 | w (int): width of maze, in number of hallways 18 | H (int): height of maze, in number of hallways + walls 19 | W (int): width of maze, in number of hallways + walls 20 | """ 21 | assert w >= 3 and h >= 3, "Mazes cannot be smaller than 3x3." 22 | self.h = h 23 | self.w = w 24 | self.H = (2 * self.h) + 1 25 | self.W = (2 * self.w) + 1 26 | 27 | @abc.abstractmethod 28 | def generate(self): 29 | return None 30 | 31 | """ All of the methods below this are helper methods, 32 | common to many maze-generating algorithms. 33 | """ 34 | 35 | def _find_neighbors(self, r, c, grid, is_wall=False): 36 | """Find all the grid neighbors of the current position; visited, or not. 37 | 38 | Args: 39 | r (int): row of cell of interest 40 | c (int): column of cell of interest 41 | grid (np.array): 2D maze grid 42 | is_wall (bool): Are we looking for neighbors that are walls, or open cells? 43 | Returns: 44 | list: all neighboring cells that match our request 45 | """ 46 | ns = [] 47 | 48 | if r > 1 and grid[r - 2][c] == is_wall: 49 | ns.append((r - 2, c)) 50 | if r < self.H - 2 and grid[r + 2][c] == is_wall: 51 | ns.append((r + 2, c)) 52 | if c > 1 and grid[r][c - 2] == is_wall: 53 | ns.append((r, c - 2)) 54 | if c < self.W - 2 and grid[r][c + 2] == is_wall: 55 | ns.append((r, c + 2)) 56 | 57 | shuffle(ns) 58 | return ns 59 | -------------------------------------------------------------------------------- /mazelib/generate/DungeonRooms.pxd: -------------------------------------------------------------------------------- 1 | cimport cython 2 | from mazelib.generate.MazeGenAlgo cimport MazeGenAlgo 3 | from numpy cimport ndarray 4 | cimport numpy as np 5 | 6 | cdef public cython.int RANDOM = 1 7 | cdef public cython.int SERPENTINE = 2 8 | 9 | 10 | cdef class DungeonRooms(MazeGenAlgo): 11 | cdef public ndarray grid 12 | cdef readonly ndarray backup_grid 13 | cdef readonly list rooms 14 | cdef readonly cython.int _hunt_order 15 | 16 | 17 | @cython.locals(current=tuple, num_trials=cython.int) 18 | cpdef ndarray[cython.char, ndim=2] generate(self) 19 | 20 | 21 | cdef inline void _carve_rooms(self, list rooms) 22 | 23 | 24 | @cython.locals(row=cython.int, col=cython.int) 25 | cdef inline void _carve_room(self, tuple top_left, tuple bottom_right) 26 | 27 | 28 | @cython.locals(i=cython.int, even_squares=list, possible_doors=list, odd_rows=list, odd_cols=list, door=tuple) 29 | cdef inline void _carve_door(self, tuple top_left, tuple bottom_right) 30 | 31 | 32 | @cython.locals(current=tuple, unvisited_neighbors=list, neighbor=tuple) 33 | cdef inline void _walk(self, tuple start) 34 | 35 | 36 | cdef inline _hunt(self, int count) 37 | 38 | 39 | cdef inline tuple _hunt_random(self, int count) 40 | 41 | 42 | @cython.locals(cell=tuple, found=bint) 43 | cdef inline tuple _hunt_serpentine(self, int count) 44 | 45 | 46 | @cython.locals(current=tuple, LIMIT=cython.int, num_tries=cython.int) 47 | cdef inline tuple _choose_start(self) 48 | 49 | 50 | cpdef void reconnect_maze(self) 51 | 52 | 53 | @cython.locals(passages=list, r=cython.int, c=cython.int, ns=list, current=set, found=bint, i=cython.int, 54 | intersect=set) 55 | cdef inline list _find_all_passages(self) 56 | 57 | 58 | @cython.locals(found=bint, cell=tuple, neighbors=list, passage=set, intersect=list, mid=tuple) 59 | cdef inline void _fix_disjoint_passages(self, list disjoint_passages) 60 | 61 | 62 | @cython.locals(r=cython.int, c=cython.int, ns=list) 63 | cdef inline list _find_unblocked_neighbors(self, tuple posi) 64 | 65 | 66 | @cython.locals(i=cython.int, j=cython.int, intersect=set, l=set) 67 | cpdef list _join_intersecting_sets(self, list list_of_sets) 68 | 69 | 70 | cdef inline tuple _midpoint(self, tuple a, tuple b) 71 | -------------------------------------------------------------------------------- /mazelib/generate/AldousBroder.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from random import choice, randrange 3 | 4 | # If the code is not Cython-compiled, we need to add some imports. 5 | from cython import compiled 6 | 7 | if not compiled: 8 | from mazelib.generate.MazeGenAlgo import MazeGenAlgo 9 | 10 | 11 | class AldousBroder(MazeGenAlgo): 12 | """ 13 | The Aldous-Broder maze-generating algorithm. 14 | 15 | 1. Choose a random cell. 16 | 2. Choose a random neighbor of the current cell and visit it. If the neighbor has not 17 | yet been visited, add the traveled edge to the spanning tree. 18 | 3. Repeat step 2 until all cells have been visited. 19 | """ 20 | 21 | def __init__(self, h, w): 22 | super(AldousBroder, self).__init__(h, w) 23 | 24 | def generate(self): 25 | """Highest-level method that implements the maze-generating algorithm. 26 | 27 | Returns 28 | ------- 29 | np.array: returned matrix 30 | """ 31 | # create empty grid, with walls 32 | grid = np.empty((self.H, self.W), dtype=np.int8) 33 | grid.fill(1) 34 | 35 | crow = randrange(1, self.H, 2) 36 | ccol = randrange(1, self.W, 2) 37 | grid[crow][ccol] = 0 38 | num_visited = 1 39 | 40 | while num_visited < self.h * self.w: 41 | # find neighbors 42 | neighbors = self._find_neighbors(crow, ccol, grid, True) 43 | 44 | # how many neighbors have already been visited? 45 | if len(neighbors) == 0: 46 | # mark random neighbor as current 47 | (crow, ccol) = choice(self._find_neighbors(crow, ccol, grid)) 48 | continue 49 | 50 | # loop through neighbors 51 | for nrow, ncol in neighbors: 52 | if grid[nrow][ncol] > 0: 53 | # open up wall to new neighbor 54 | grid[(nrow + crow) // 2][(ncol + ccol) // 2] = 0 55 | # mark neighbor as visited 56 | grid[nrow][ncol] = 0 57 | # bump the number visited 58 | num_visited += 1 59 | # current becomes new neighbor 60 | crow = nrow 61 | ccol = ncol 62 | # break loop 63 | break 64 | 65 | return grid 66 | -------------------------------------------------------------------------------- /mazelib/generate/BinaryTree.py: -------------------------------------------------------------------------------- 1 | from mazelib.generate.MazeGenAlgo import np 2 | from random import choice 3 | 4 | # If the code is not Cython-compiled, we need to add some imports. 5 | from cython import compiled 6 | 7 | if not compiled: 8 | from mazelib.generate.MazeGenAlgo import MazeGenAlgo 9 | 10 | 11 | class BinaryTree(MazeGenAlgo): 12 | """ 13 | The Binary Tree maze-generating algorithm. 14 | 15 | For every cell in the grid, knock down a wall either North or West. 16 | """ 17 | 18 | def __init__(self, w, h, skew=None): 19 | super(BinaryTree, self).__init__(w, h) 20 | skewes = { 21 | "NW": [(1, 0), (0, -1)], 22 | "NE": [(1, 0), (0, 1)], 23 | "SW": [(-1, 0), (0, -1)], 24 | "SE": [(-1, 0), (0, 1)], 25 | } 26 | if skew in skewes: 27 | self.skew = skewes[skew] 28 | else: 29 | key = choice(list(skewes.keys())) 30 | self.skew = skewes[key] 31 | 32 | def generate(self): 33 | """Highest-level method that implements the maze-generating algorithm. 34 | 35 | Returns 36 | ------- 37 | np.array: returned matrix 38 | """ 39 | # create empty grid, with walls 40 | grid = np.empty((self.H, self.W), dtype=np.int8) 41 | grid.fill(1) 42 | 43 | for row in range(1, self.H, 2): 44 | for col in range(1, self.W, 2): 45 | grid[row][col] = 0 46 | neighbor_row, neighbor_col = self._find_neighbor(row, col) 47 | grid[neighbor_row][neighbor_col] = 0 48 | 49 | return grid 50 | 51 | def _find_neighbor(self, current_row, current_col): 52 | """Find a neighbor in the skewed direction. 53 | 54 | Args: 55 | current_row (int): row number 56 | current_col (int): col number 57 | Returns: 58 | tuple: position of the randomly-chosen neighbor 59 | """ 60 | neighbors = [] 61 | for b_row, b_col in self.skew: 62 | neighbor_row = current_row + b_row 63 | neighbor_col = current_col + b_col 64 | if neighbor_row > 0 and neighbor_row < (self.H - 1): 65 | if neighbor_col > 0 and neighbor_col < (self.W - 1): 66 | neighbors.append((neighbor_row, neighbor_col)) 67 | 68 | if len(neighbors) == 0: 69 | return (current_row, current_col) 70 | else: 71 | return choice(neighbors) 72 | -------------------------------------------------------------------------------- /mazelib/generate/GrowingTree.py: -------------------------------------------------------------------------------- 1 | from random import choice, random, randrange 2 | import numpy as np 3 | 4 | # If the code is not Cython-compiled, we need to add some imports. 5 | from cython import compiled 6 | 7 | if not compiled: 8 | from mazelib.generate.MazeGenAlgo import MazeGenAlgo 9 | 10 | 11 | class GrowingTree(MazeGenAlgo): 12 | """ 13 | The Growing-Tree maze-generating algorithm. 14 | 15 | 1. Let C be a list of cells, initially empty. Add one cell to C, at random. 16 | 2. Choose a cell from C, and carve a passage to any unvisited neighbor of that cell, 17 | adding that neighbor to C as well. If there are no unvisited neighbors, 18 | remove the cell from C. 19 | 3. Repeat step 2 until C is empty. 20 | 21 | Optional Parameters 22 | 23 | backtrack_chance: float [0.0, 1.0] 24 | Splits the logic to either use Recursive Backtracking (RB) or Prim's (random) 25 | to select the next cell to visit. (default 1.0) 26 | """ 27 | 28 | def __init__(self, w, h, backtrack_chance=1.0): 29 | super(GrowingTree, self).__init__(w, h) 30 | self.backtrack_chance = backtrack_chance 31 | 32 | def generate(self): 33 | """Highest-level method that implements the maze-generating algorithm. 34 | 35 | Returns 36 | ------- 37 | np.array: returned matrix 38 | """ 39 | # create empty grid 40 | grid = np.empty((self.H, self.W), dtype=np.int8) 41 | grid.fill(1) 42 | 43 | current_row, current_col = (randrange(1, self.H, 2), randrange(1, self.W, 2)) 44 | grid[current_row][current_col] = 0 45 | active = [(current_row, current_col)] 46 | 47 | # continue until you have no more neighbors to move to 48 | while active: 49 | if random() < self.backtrack_chance: 50 | current_row, current_col = active[-1] 51 | else: 52 | current_row, current_col = choice(active) 53 | 54 | # find a visited neighbor 55 | next_neighbors = self._find_neighbors(current_row, current_col, grid, True) 56 | if len(next_neighbors) == 0: 57 | active = [a for a in active if a != (current_row, current_col)] 58 | continue 59 | 60 | nn_row, nn_col = choice(next_neighbors) 61 | active += [(nn_row, nn_col)] 62 | 63 | grid[nn_row][nn_col] = 0 64 | grid[(current_row + nn_row) // 2][(current_col + nn_col) // 2] = 0 65 | 66 | return grid 67 | -------------------------------------------------------------------------------- /mazelib/generate/Prims.py: -------------------------------------------------------------------------------- 1 | from random import randrange 2 | import numpy as np 3 | 4 | # If the code is not Cython-compiled, we need to add some imports. 5 | from cython import compiled 6 | 7 | if not compiled: 8 | from mazelib.generate.MazeGenAlgo import MazeGenAlgo 9 | 10 | 11 | class Prims(MazeGenAlgo): 12 | """ 13 | The Prims maze-generating algorithm. 14 | 15 | 1. Choose an arbitrary cell from the grid, and add it to some 16 | (initially empty) set visited nodes (V). 17 | 2. Randomly select a wall from the grid that connects a cell in 18 | V with another cell not in V. 19 | 3. Add that wall to the Minimal Spanning Tree (MST), and the edge's other cell to V. 20 | 4. Repeat steps 2 and 3 until V includes every cell in G. 21 | """ 22 | 23 | def __init__(self, h, w): 24 | super(Prims, self).__init__(h, w) 25 | 26 | def generate(self): 27 | """Highest-level method that implements the maze-generating algorithm. 28 | 29 | Returns 30 | ------- 31 | np.array: returned matrix 32 | """ 33 | # create empty grid 34 | grid = np.empty((self.H, self.W), dtype=np.int8) 35 | grid.fill(1) 36 | 37 | # choose a random starting position 38 | current_row = randrange(1, self.H, 2) 39 | current_col = randrange(1, self.W, 2) 40 | grid[current_row][current_col] = 0 41 | 42 | # created a weighted list of all vertices connected in the graph 43 | neighbors = self._find_neighbors(current_row, current_col, grid, True) 44 | 45 | # loop over all current neighbors, until empty 46 | visited = 1 47 | 48 | while visited < self.h * self.w: 49 | # find neighbor with lowest weight, make it current 50 | nn = randrange(len(neighbors)) 51 | current_row, current_col = neighbors[nn] 52 | visited += 1 53 | grid[current_row][current_col] = 0 54 | neighbors = neighbors[:nn] + neighbors[nn + 1 :] 55 | # connect that neighbor to a random neighbor with grid[posi] == 0 56 | nearest_n0, nearest_n1 = self._find_neighbors( 57 | current_row, current_col, grid 58 | )[0] 59 | grid[(current_row + nearest_n0) // 2][(current_col + nearest_n1) // 2] = 0 60 | 61 | # find all unvisited neighbors of current, add them to neighbors 62 | unvisited = self._find_neighbors(current_row, current_col, grid, True) 63 | neighbors = list(set(neighbors + unvisited)) 64 | 65 | return grid 66 | -------------------------------------------------------------------------------- /mazelib/generate/Sidewinder.py: -------------------------------------------------------------------------------- 1 | from random import choice, random 2 | import numpy as np 3 | 4 | # If the code is not Cython-compiled, we need to add some imports. 5 | from cython import compiled 6 | 7 | if not compiled: 8 | from mazelib.generate.MazeGenAlgo import MazeGenAlgo 9 | 10 | 11 | class Sidewinder(MazeGenAlgo): 12 | """The Side-Windwinder maze-generating algorithm. 13 | 14 | 1. Work through the grid row-wise, starting with the cell at 0,0. 15 | 2. Add the current cell to a "run" set. 16 | 3. For the current cell, randomly decide whether to carve East. 17 | 4. If a passage East was carved, make the new cell the current cell and repeat steps 2-4. 18 | 5. If a passage East was not carved, choose any one of the cells in the run set and carve 19 | a passage North. Then empty the run set. Repeat steps 2-5. 20 | 6. Continue until all rows have been processed. 21 | 22 | Optional Parameters 23 | 24 | skew: Float [0.0, 1.0] 25 | If the skew is set less than 0.5 the maze will be skewed East-West, if it set greater 26 | than 0.5 it will be skewed North-South. (default 0.5) 27 | """ 28 | 29 | def __init__(self, h, w, skew=0.5): 30 | super(Sidewinder, self).__init__(h, w) 31 | self.skew = skew 32 | 33 | def generate(self): 34 | """Highest-level method that implements the maze-generating algorithm. 35 | 36 | Returns 37 | ------- 38 | np.array: returned matrix 39 | """ 40 | # create empty grid 41 | grid = np.empty((self.H, self.W), dtype=np.int8) 42 | grid.fill(1) 43 | 44 | # The first row is always empty, because you can't carve North 45 | for col in range(1, self.W - 1): 46 | grid[1][col] = 0 47 | 48 | # loop through the remaining rows and columns 49 | for row in range(3, self.H, 2): 50 | # create a run of cells 51 | run = [] 52 | 53 | for col in range(1, self.W, 2): 54 | # remove the wall to the current cell 55 | grid[row][col] = 0 56 | # add the current cell to the run 57 | run.append((row, col)) 58 | 59 | carve_east = random() > self.skew 60 | # carve East or North (can't carve East into the East wall 61 | if carve_east and col < (self.W - 2): 62 | grid[row][col + 1] = 0 63 | else: 64 | north = choice(run) 65 | grid[north[0] - 1][north[1]] = 0 66 | run = [] 67 | 68 | return grid 69 | -------------------------------------------------------------------------------- /mazelib/generate/CellularAutomaton.py: -------------------------------------------------------------------------------- 1 | from random import choice, randrange 2 | import numpy as np 3 | 4 | # If the code is not Cython-compiled, we need to add some imports. 5 | from cython import compiled 6 | 7 | if not compiled: 8 | from mazelib.generate.MazeGenAlgo import MazeGenAlgo 9 | 10 | 11 | class CellularAutomaton(MazeGenAlgo): 12 | """The Cellular Automaton maze-generating algorithm. 13 | 14 | Cells survive if they have one to four neighbours. 15 | If a cell has exactly three neighbours, it is born. 16 | 17 | It is similar to Conway's Game of Life in that patterns 18 | that do not have a living cell adjacent to 1, 4, or 5 other 19 | living cells in any generation will behave identically to it. 20 | """ 21 | 22 | def __init__(self, w, h, complexity=1.0, density=1.0): 23 | super(CellularAutomaton, self).__init__(w, h) 24 | self.complexity = complexity 25 | self.density = density 26 | 27 | def generate(self): 28 | """Highest-level method that implements the maze-generating algorithm. 29 | 30 | Returns 31 | ------- 32 | np.array: returned matrix 33 | """ 34 | # create empty grid 35 | grid = np.empty((self.H, self.W), dtype=np.int8) 36 | grid.fill(0) 37 | # fill borders 38 | grid[0, :] = grid[-1, :] = 1 39 | grid[:, 0] = grid[:, -1] = 1 40 | 41 | # adjust complexity and density relative to maze size 42 | if self.complexity <= 1.0: 43 | self.complexity = self.complexity * (self.h + self.w) 44 | if self.density <= 1.0: 45 | self.density = self.density * (self.h * self.w) 46 | 47 | # create walls 48 | for i in range(int(2 * self.density)): 49 | # choose a starting location 50 | if i < (self.density): 51 | # we want to make sure we have a lot of walls that touch the outsie of the maze 52 | if choice([0, 1]): 53 | y = choice([0, self.H - 1]) 54 | x = randrange(0, self.W, 2) 55 | else: 56 | x = choice([0, self.W - 1]) 57 | y = randrange(0, self.H, 2) 58 | else: 59 | # let's try to fill in any voids we left in the maze 60 | y, x = randrange(0, self.H, 2), randrange(0, self.W, 2) 61 | 62 | # build a wall through the maze 63 | grid[y, x] = 1 64 | for j in range(int(self.complexity)): 65 | neighbors = self._find_neighbors(y, x, grid, True) # is wall 66 | if len(neighbors) > 0 and len(neighbors) < 4: 67 | neighbors = self._find_neighbors(y, x, grid, False) # is open 68 | if not len(neighbors): 69 | continue 70 | r, c = choice(neighbors) 71 | if grid[r, c] == 0: 72 | grid[r, c] = 1 73 | grid[r + (y - r) // 2, c + (x - c) // 2] = 1 74 | x, y = c, r 75 | 76 | return grid 77 | -------------------------------------------------------------------------------- /benchmarks.py: -------------------------------------------------------------------------------- 1 | """The benchmarks below are useful for testing performance when making changes to the maze algorithms.""" 2 | 3 | from datetime import datetime 4 | from sysconfig import get_python_version 5 | from timeit import Timer 6 | from mazelib import __version__ as version 7 | 8 | # CONFIG 9 | SIZES = [5, 10, 25, 50, 100] 10 | ITERATIONS = [100, 50, 20, 5, 1] 11 | GENERATORS = [ 12 | "AldousBroder", 13 | "BacktrackingGenerator", 14 | "BinaryTree", 15 | "HuntAndKill", 16 | "Prims", 17 | "Sidewinder", 18 | "TrivialMaze", 19 | "Wilsons", 20 | ] 21 | SOLVERS = ["Collision", "Tremaux"] 22 | 23 | 24 | def main(): 25 | times = run_benchmarks() 26 | print_benchmarks(times) 27 | 28 | 29 | def run_benchmarks(): 30 | """Run the benchmarks. 31 | An annoying screen-print will occur so that you know your progress, as these tests might take a while. 32 | 33 | Returns 34 | ------- 35 | list: 2D list of the team each generator/solver combination took 36 | """ 37 | times = [[0.0] * len(SIZES) for _ in range(len(GENERATORS) * len(SOLVERS))] 38 | 39 | row = -1 40 | for generator in GENERATORS: 41 | for solver in SOLVERS: 42 | row += 1 43 | print("Run #%d: %s & %s" % (row, generator, solver)) 44 | for col, size in enumerate(SIZES): 45 | print(col) 46 | setup = """from mazelib import Maze 47 | from mazelib.solve.%(solv)s import %(solv)s 48 | from mazelib.generate.%(gen)s import %(gen)s 49 | """ % { 50 | "solv": solver, 51 | "gen": generator, 52 | } 53 | logic = """m = Maze() 54 | m.generator = %(gen)s(%(size)d, %(size)d) 55 | m.solver = %(solv)s() 56 | m.generate() 57 | m.generate_entrances() 58 | m.solve() 59 | """ % { 60 | "solv": solver, 61 | "gen": generator, 62 | "size": size, 63 | } 64 | t = Timer(logic, setup=setup) 65 | time = t.timeit(ITERATIONS[col]) 66 | times[row] 67 | times[row][col] = time 68 | 69 | return times 70 | 71 | 72 | def print_benchmarks(times): 73 | """Pretty print for the benchmark results, with a detailed CSV at the end. 74 | 75 | Args: 76 | times (list): timing results for the benchmark runs 77 | Results: None 78 | """ 79 | print("\nmazelib benchmarking") 80 | print(datetime.now().strftime("%Y-%m-%d %H:%M")) 81 | print("Python version: {0}".format(get_python_version())) 82 | print("mazelib version: {0}".format(version)) 83 | print( 84 | "\nTotal Time (seconds): %.5f\n" % sum([sum(times_row) for times_row in times]) 85 | ) 86 | print("\nmaze size," + ",".join([str(s) for s in SIZES])) 87 | 88 | row = -1 89 | for generator in GENERATORS: 90 | for solver in SOLVERS: 91 | row += 1 92 | method = generator + "-" + solver + "," 93 | print(method + ",".join(["%.5f" % time for time in times[row]])) 94 | 95 | 96 | if __name__ == "__main__": 97 | main() 98 | -------------------------------------------------------------------------------- /mazelib/generate/Kruskal.py: -------------------------------------------------------------------------------- 1 | from numpy.random import shuffle 2 | import numpy as np 3 | 4 | # If the code is not Cython-compiled, we need to add some imports. 5 | from cython import compiled 6 | 7 | if not compiled: 8 | from mazelib.generate.MazeGenAlgo import MazeGenAlgo 9 | 10 | 11 | class Kruskal(MazeGenAlgo): 12 | """The Kruskal maze-generating algorithm.""" 13 | 14 | def __init__(self, h, w): 15 | super(Kruskal, self).__init__(h, w) 16 | 17 | def generate(self): 18 | """Highest-level method that implements the maze-generating algorithm. 19 | 20 | Returns 21 | ------- 22 | np.array: returned matrix 23 | """ 24 | # create empty grid 25 | grid = np.empty((self.H, self.W), dtype=np.int8) 26 | grid.fill(1) 27 | 28 | forest = [] 29 | for row in range(1, self.H - 1, 2): 30 | for col in range(1, self.W - 1, 2): 31 | forest.append([(row, col)]) 32 | grid[row][col] = 0 33 | 34 | edges = [] 35 | for row in range(2, self.H - 1, 2): 36 | for col in range(1, self.W - 1, 2): 37 | edges.append((row, col)) 38 | for row in range(1, self.H - 1, 2): 39 | for col in range(2, self.W - 1, 2): 40 | edges.append((row, col)) 41 | 42 | shuffle(edges) 43 | 44 | while len(forest) > 1: 45 | ce_row, ce_col = edges[0] 46 | edges = edges[1:] 47 | 48 | tree1 = -1 49 | tree2 = -1 50 | 51 | if ce_row % 2 == 0: # even-numbered row: vertical wall 52 | tree1 = sum( 53 | [ 54 | i if (ce_row - 1, ce_col) in j else 0 55 | for i, j in enumerate(forest) 56 | ] 57 | ) 58 | tree2 = sum( 59 | [ 60 | i if (ce_row + 1, ce_col) in j else 0 61 | for i, j in enumerate(forest) 62 | ] 63 | ) 64 | else: # odd-numbered row: horizontal wall 65 | tree1 = sum( 66 | [ 67 | i if (ce_row, ce_col - 1) in j else 0 68 | for i, j in enumerate(forest) 69 | ] 70 | ) 71 | tree2 = sum( 72 | [ 73 | i if (ce_row, ce_col + 1) in j else 0 74 | for i, j in enumerate(forest) 75 | ] 76 | ) 77 | 78 | if tree1 != tree2: 79 | new_tree = forest[tree1] + forest[tree2] 80 | temp1 = list(forest[tree1]) 81 | temp2 = list(forest[tree2]) 82 | forest = [ 83 | x for x in forest if x != temp1 84 | ] # faster than forest.remove(temp1) 85 | forest = [x for x in forest if x != temp2] 86 | forest.append(new_tree) 87 | grid[ce_row][ce_col] = 0 88 | 89 | return grid 90 | -------------------------------------------------------------------------------- /mazelib/transmute/CuldeSacFiller.py: -------------------------------------------------------------------------------- 1 | # If the code is not Cython-compiled, we need to add some imports. 2 | from cython import compiled 3 | 4 | if not compiled: 5 | from mazelib.transmute.MazeTransmuteAlgo import MazeTransmuteAlgo 6 | 7 | 8 | class CuldeSacFiller(MazeTransmuteAlgo): 9 | """ 10 | The Cul-de-Sac Filler maze transmutation algorithm. 11 | 12 | This algorithm could be called LoopFiller, because it breaks up loop in the maze. 13 | 14 | 1. Scan the maze, looking for cells with connecting halls that go in exactly two directions. 15 | 2. At each of these places, travel in both directions until you find your first intersection. 16 | 3. If the first intersection for both paths is the same, you have a loop. 17 | 4. Fill in the cell you started at with a wall, breaking the loop. 18 | """ 19 | 20 | def _transmute(self): 21 | """Master method to fill in all the loops in the maze.""" 22 | for r in range(1, self.grid.shape[0], 2): 23 | for c in range(1, self.grid.shape[1], 2): 24 | if (r, c) in (self.start, self.end): 25 | # we don't want to block off an exit 26 | continue 27 | elif self.grid[(r, c)] == 1: 28 | # it's a wall, who cares 29 | continue 30 | 31 | # determine if we could even possibly be in a loop 32 | ns = self._find_unblocked_neighbors((r, c)) 33 | if len(ns) != 2: 34 | continue 35 | 36 | # travel in both directions until you hit the first intersection 37 | try: 38 | end1 = self._find_next_intersection([(r, c), ns[0]]) 39 | end2 = self._find_next_intersection([(r, c), ns[1]]) 40 | except AssertionError: 41 | continue 42 | 43 | # Found a loop! 44 | if end1 == end2: 45 | self.grid[(r, c)] = 1 46 | 47 | def _find_next_intersection(self, path_start): 48 | """Starting with the first two cells in a path, follow the path until you hit the next 49 | intersection (or dead end). 50 | 51 | Args: 52 | path_start (list): the first two cells (tuples) in the path you want to travel 53 | Returns: 54 | tuple: the location of the first intersection (or dead end) in the maze 55 | """ 56 | assert len(path_start) == 2, "invalid starting path to travel" 57 | 58 | # save off starting positions for comparisons later 59 | first = path_start[0] 60 | previous = path_start[0] 61 | current = path_start[1] 62 | 63 | # keep traveling until you hit an intersection 64 | ns = self._find_unblocked_neighbors(current) 65 | while len(ns) == 2: 66 | # travel away from where you came from 67 | if ns[0] == previous: 68 | previous = current 69 | current = ns[1] 70 | else: 71 | previous = current 72 | current = ns[0] 73 | 74 | # Edge Case: You looped without finding ANY intersections? Eww. 75 | if current == first: 76 | return previous 77 | 78 | # look around for you next traveling position 79 | ns = self._find_unblocked_neighbors(current) 80 | 81 | return current 82 | -------------------------------------------------------------------------------- /docs/MAZE_TRANSMUTE_ALGOS.md: -------------------------------------------------------------------------------- 1 | # Maze-Transmuting Algorithms 2 | 3 | ##### Go back to the main [README](../README.md) 4 | 5 | 6 | ## Cul-de-sac Filler 7 | 8 |  9 | 10 | ###### The Algorithm 11 | 12 | 1. Scan the maze, looking for cells with connecting halls that go in exactly two directions. 13 | 2. At each of these places, travel in both directions until you find your first intersection. 14 | 3. If the first intersection for both paths is the same, you have a loop. 15 | 4. Fill in the cell you started at with a wall, breaking the loop. 16 | 17 | ###### Results 18 | 19 | * This works great for simple loops, and even multi-loops. It would probably fail for big, empty rules. 20 | 21 | ###### Notes 22 | 23 | This is a classic algorithm. However, it seems fairly slow by design. Still, if your maze has many cul-de-sacs / loops it could be very helpful. 24 | 25 | 26 | ## Dead End Filler 27 | 28 |  29 | 30 | ###### The Algorithm 31 | 32 | 1. Scan the maze in any order, looking for dead ends. 33 | 2. Fill in each dead end, and the dead-end passages attached to them. 34 | 35 | ###### Results 36 | 37 | * Run this algorithm enough times on a perfect maze and it will leave only the solution cells open! 38 | 39 | ###### Notes 40 | 41 | If you generate a maze which is just *all* loops (called a heavily braided maze), this algorithm won't do much. But it nearly all other scenarios it works like a charm. 42 | 43 | 44 | ## Perturbation 45 | 46 |  47 | 48 | ###### The Algorithm 49 | 50 | 1. Start with a complete, valid maze. 51 | 2. Add a small number of random walls, blocking current passages. 52 | 3. Go through the maze and reconnect all passages that are not currently open, by randomly opening walls. 53 | 4. Repeat steps 3 and 4 a prescribed number of times. 54 | 55 | ###### Optional Parameters 56 | 57 | * *new_walls*: Integer [1, 2, ...] 58 | * The number of randomly positioned new walls you create throughout the maze. (default 1) 59 | * *repeat*: Integer [1, 2, ...] 60 | * The number of times sets of new walls will be added to the maze; the maze being fixed after each set. (default 1) 61 | 62 | ###### Results 63 | 64 | This usually produces perfect mazes (if the input maze was perfect). But there is a small chance that not all inner walls of the maze will be fully connected. 65 | 66 | ###### Notes 67 | 68 | In math and physics, perturbation theory is idea that you can solve a new problem by starting with the known solution to an old problem and making a small change. Here, we take a previously-generated maze and perturb it slightly by adding a couple walls, then re-open any parts of the maze we have closed off. This is an amazingly powerful tool. It can fix nearly any flaw in a maze. Or you can start with a non-maze, say a nice spiral or a cute drawing, and turn it into a maze using first-order perturbations. 69 | 70 | With great power comes great responsibility. If you use this method on a grid that does not contain a maze, it will fail. If you run too many iterations of this algorithm, your end maze will look nothing like the original. But if used wisely, this is an extremely powerful tool. 71 | 72 | 73 | ## Vocabulary 74 | 75 | 1. __cell__ - an open passage in the maze 76 | 2. __grid__ - the grid is the combination of all passages and barriers in the maze 77 | 3. __perfect__ - a maze is perfect if it has one and only one solution 78 | 4. __wall__ - an impassable barrier in the maze 79 | 80 | 81 | ##### Go back to the main [README](../README.md) 82 | -------------------------------------------------------------------------------- /mazelib/generate/Division.py: -------------------------------------------------------------------------------- 1 | from mazelib.generate.MazeGenAlgo import np 2 | from random import randrange 3 | 4 | # If the code is not Cython-compiled, we need to add some imports. 5 | from cython import compiled 6 | 7 | if not compiled: 8 | from mazelib.generate.MazeGenAlgo import MazeGenAlgo 9 | 10 | # CONSTANTS 11 | VERTICAL = 0 12 | HORIZONTAL = 1 13 | 14 | 15 | class Division(MazeGenAlgo): 16 | """The Division maze-generating algorithm. 17 | 18 | 1. Start with an empty grid. 19 | 2. Build a wall that bisects the grid (horizontal or vertical). Add a single passage through the wall. 20 | 3. Repeat step 2 with the areas on either side of the wall. 21 | 4. Continue, recursively, until the maze passages are the desired resolution. 22 | """ 23 | 24 | def __init__(self, h, w): 25 | super(Division, self).__init__(h, w) 26 | 27 | def generate(self): 28 | """Highest-level method that implements the maze-generating algorithm. 29 | 30 | Returns 31 | ------- 32 | np.array: returned matrix 33 | """ 34 | # create empty grid 35 | grid = np.empty((self.H, self.W), dtype=np.int8) 36 | grid.fill(0) 37 | # fill borders 38 | grid[0, :] = grid[-1, :] = 1 39 | grid[:, 0] = grid[:, -1] = 1 40 | 41 | region_stack = [((1, 1), (self.H - 2, self.W - 2))] 42 | 43 | while region_stack: 44 | current_region = region_stack[-1] 45 | region_stack = region_stack[:-1] 46 | min_y = current_region[0][0] 47 | max_y = current_region[1][0] 48 | min_x = current_region[0][1] 49 | max_x = current_region[1][1] 50 | height = max_y - min_y + 1 51 | width = max_x - min_x + 1 52 | 53 | if height <= 1 or width <= 1: 54 | continue 55 | 56 | if width < height: 57 | cut_direction = HORIZONTAL # with 100% chance 58 | elif width > height: 59 | cut_direction = VERTICAL # with 100% chance 60 | else: 61 | if width == 2: 62 | continue 63 | cut_direction = randrange(2) 64 | 65 | # MAKE CUT 66 | # select cut position (can't be completely on the edge of the region) 67 | cut_length = (height, width)[(cut_direction + 1) % 2] 68 | if cut_length < 3: 69 | continue 70 | cut_posi = randrange(1, cut_length, 2) 71 | # select new door position 72 | door_posi = randrange(0, (height, width)[cut_direction], 2) 73 | # add walls to correct places 74 | if cut_direction == 0: # vertical 75 | for row in range(min_y, max_y + 1): 76 | grid[row, min_x + cut_posi] = 1 77 | grid[min_y + door_posi, min_x + cut_posi] = 0 78 | else: # horizontal 79 | for col in range(min_x, max_x + 1): 80 | grid[min_y + cut_posi, col] = 1 81 | grid[min_y + cut_posi, min_x + door_posi] = 0 82 | 83 | # add new regions to stack 84 | if cut_direction == 0: # vertical 85 | region_stack.append(((min_y, min_x), (max_y, min_x + cut_posi - 1))) 86 | region_stack.append(((min_y, min_x + cut_posi + 1), (max_y, max_x))) 87 | else: # horizontal 88 | region_stack.append(((min_y, min_x), (min_y + cut_posi - 1, max_x))) 89 | region_stack.append(((min_y + cut_posi + 1, min_x), (max_y, max_x))) 90 | 91 | return grid 92 | -------------------------------------------------------------------------------- /mazelib/transmute/MazeTransmuteAlgo.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from numpy.random import shuffle 3 | 4 | 5 | class MazeTransmuteAlgo: 6 | __metaclass__ = abc.ABCMeta 7 | 8 | def __init__(self): 9 | self.grid = None 10 | self.start = None 11 | self.end = None 12 | 13 | def transmute(self, grid, start, end): 14 | """Primary transmute method, first setting the maze of interest. 15 | 16 | Args: 17 | grid (np.array): maze array 18 | start (tuple): position to begin from 19 | end (tuple): goal position 20 | Returns: None 21 | """ 22 | self.grid = grid 23 | self.start = start 24 | self.end = end 25 | self._transmute() 26 | 27 | @abc.abstractmethod 28 | def _transmute(self): 29 | pass 30 | 31 | """ 32 | All of the methods below this are helper methods, 33 | common to many maze-transmuting algorithms. 34 | """ 35 | 36 | def _find_unblocked_neighbors(self, posi): 37 | """Find all the grid neighbors of the current position; visited, or not. 38 | 39 | Args: 40 | posi (tuple): cell of interest 41 | Returns: 42 | list: all open, unblocked neighboring maze positions 43 | """ 44 | r, c = posi 45 | ns = [] 46 | 47 | if r > 1 and not self.grid[r - 1, c] and not self.grid[r - 2, c]: 48 | ns.append((r - 2, c)) 49 | if ( 50 | r < self.grid.shape[0] - 2 51 | and not self.grid[r + 1, c] 52 | and not self.grid[r + 2, c] 53 | ): 54 | ns.append((r + 2, c)) 55 | if c > 1 and not self.grid[r, c - 1] and not self.grid[r, c - 2]: 56 | ns.append((r, c - 2)) 57 | if ( 58 | c < self.grid.shape[1] - 2 59 | and not self.grid[r, c + 1] 60 | and not self.grid[r, c + 2] 61 | ): 62 | ns.append((r, c + 2)) 63 | 64 | shuffle(ns) 65 | 66 | return ns 67 | 68 | def _find_neighbors(self, r, c, is_wall=False): 69 | """Find all the grid neighbors of the current position; visited, or not. 70 | 71 | Args: 72 | r (int): row number 73 | c (int): column number 74 | is_wall (bool): Are we interesting in walls or open hallways? 75 | Returns: 76 | list: all neighboring maze positions 77 | """ 78 | ns = [] 79 | 80 | if r > 1 and self.grid[r - 2][c] == is_wall: 81 | ns.append((r - 2, c)) 82 | if r < self.grid.shape[0] - 2 and self.grid[r + 2][c] == is_wall: 83 | ns.append((r + 2, c)) 84 | if c > 1 and self.grid[r][c - 2] == is_wall: 85 | ns.append((r, c - 2)) 86 | if c < self.grid.shape[1] - 2 and self.grid[r][c + 2] == is_wall: 87 | ns.append((r, c + 2)) 88 | 89 | shuffle(ns) 90 | 91 | return ns 92 | 93 | def _within_one(self, cell, desire): 94 | """Test if the current cell within one move of the desired cell. 95 | 96 | Note, this might be one full more, or one half move. 97 | 98 | Args: 99 | cell (tuple): cell of interest 100 | desire (tuple): target cell 101 | Returns: 102 | bool: Are the input cells within one step of each other? 103 | """ 104 | if not cell or not desire: 105 | return False 106 | 107 | if cell[0] == desire[0]: 108 | if abs(cell[1] - desire[1]) < 2: 109 | return True 110 | elif cell[1] == desire[1]: 111 | if abs(cell[0] - desire[0]) < 2: 112 | return True 113 | 114 | return False 115 | 116 | def _midpoint(self, a, b): 117 | """Find the wall cell between to passage cells. 118 | 119 | Args: 120 | a (tuple): cell of interest 121 | b (tuple): target cell 122 | Returns: 123 | tuple: cell halfway between those provided 124 | """ 125 | return (a[0] + b[0]) // 2, (a[1] + b[1]) // 2 126 | -------------------------------------------------------------------------------- /mazelib/transmute/DeadEndFiller.py: -------------------------------------------------------------------------------- 1 | # If the code is not Cython-compiled, we need to add some imports. 2 | from cython import compiled 3 | 4 | if not compiled: 5 | from mazelib.transmute.MazeTransmuteAlgo import MazeTransmuteAlgo 6 | 7 | 8 | class DeadEndFiller(MazeTransmuteAlgo): 9 | """ 10 | The Dead-End Filler maze transmutation algorithm. 11 | 12 | 1. Scan the maze in any order, looking for dead ends. 13 | 2. Fill in each dead end, and any dead-end passages attached to them. 14 | 15 | Optionally, run the above multiple times to find more dead ends. 16 | Eventually, if you start with a perfect maze, you will end up with only solution cells left 17 | open in your maze. 18 | """ 19 | 20 | def __init__(self, iterations=1): 21 | self.iterations = int(iterations) if iterations > 0 else 100 22 | super(DeadEndFiller, self).__init__() 23 | 24 | def _transmute(self): 25 | """Primary method to fill in all the dead ends in the maze.""" 26 | # make sure we don't block off the entrances 27 | r, c = self.start 28 | start_save = self.grid[r, c] 29 | self.grid[r, c] = 0 30 | r, c = self.end 31 | end_save = self.grid[r, c] 32 | self.grid[r, c] = 0 33 | 34 | # block off all the dead ends N times 35 | found = True 36 | i = 0 37 | while found and i < self.iterations: 38 | i += 1 39 | found = self._fill_dead_ends() 40 | 41 | # re-set start and end 42 | r, c = self.start 43 | self.grid[r, c] = start_save 44 | r, c = self.end 45 | self.grid[r, c] = end_save 46 | 47 | def _fill_dead_ends(self): 48 | """Fill all dead ends in the maze. 49 | 50 | Returns 51 | ------- 52 | bool: Where any dead ends found in the maze? 53 | """ 54 | # loop through the maze serpentine, and find dead ends 55 | dead_end = self._find_dead_end() 56 | found = False 57 | while dead_end != (-1, -1): 58 | found = True 59 | 60 | # fill-in and wall-off the dead end 61 | self._fill_dead_end(dead_end) 62 | 63 | # from the dead end, travel one cell 64 | ns = self._find_unblocked_neighbors(dead_end) 65 | 66 | if len(ns) == 0: 67 | break 68 | 69 | # look at the next cell, if it is a dead end, restart the loop 70 | if len(ns) == 1: 71 | # continue until you are in a junction cell 72 | if self._is_dead_end(ns[0]): 73 | dead_end = ns[0] 74 | continue 75 | 76 | # otherwise, find another dead end in the maze 77 | dead_end = self._find_dead_end() 78 | 79 | return found 80 | 81 | def _fill_dead_end(self, dead_end): 82 | """After moving from a dead end, we want to fill in it and all the walls around it. 83 | 84 | Args: 85 | dead_end (tuple): position of the dead end we want to fill in 86 | Returns: None 87 | """ 88 | r, c = dead_end 89 | self.grid[r, c] = 1 90 | self.grid[r - 1, c] = 1 91 | self.grid[r + 1, c] = 1 92 | self.grid[r, c - 1] = 1 93 | self.grid[r, c + 1] = 1 94 | 95 | def _find_dead_end(self): 96 | """A "dead end" is a cell with only zero or one open neighbors. The start end end count as open. 97 | 98 | Returns 99 | ------- 100 | tuple: position of another dead end in the maze. (returns (-1, -1) if one can't be found) 101 | """ 102 | for r in range(1, self.grid.shape[0], 2): 103 | for c in range(1, self.grid.shape[1], 2): 104 | if self._within_one((r, c), self.start): 105 | continue 106 | elif self._within_one((r, c), self.end): 107 | continue 108 | elif self._is_dead_end((r, c)): 109 | return (r, c) 110 | 111 | return (-1, -1) 112 | 113 | def _is_dead_end(self, cell): 114 | """Test if this cell a dead end. A dead end has zero or one open neighbors. 115 | 116 | Args: 117 | cell (tuple): maze position of interest 118 | Returns: 119 | bool: Is this cell a dead end? 120 | """ 121 | ns = self._find_unblocked_neighbors(cell) 122 | 123 | if self.grid[cell[0], cell[1]] == 1: 124 | return False 125 | else: 126 | return len(ns) < 2 127 | -------------------------------------------------------------------------------- /mazelib/generate/TrivialMaze.py: -------------------------------------------------------------------------------- 1 | from random import randint 2 | import numpy as np 3 | 4 | # If the code is not Cython-compiled, we need to add some imports. 5 | from cython import compiled 6 | 7 | if not compiled: 8 | from mazelib.generate.MazeGenAlgo import MazeGenAlgo 9 | 10 | SERPENTINE = 1 11 | SPIRAL = 2 12 | 13 | 14 | class TrivialMaze(MazeGenAlgo): 15 | """A algorithm for generating a trivial maze. 16 | 17 | This is actually a collection of little tools to make simple, 18 | unicursal mazes. Currently, there are two trivial mazes available: 19 | serpentine and spiral. 20 | """ 21 | 22 | def __init__(self, h, w, maze_type="spiral"): 23 | if maze_type.lower().strip() == "serpentine": 24 | self.maze_type = SERPENTINE 25 | else: 26 | self.maze_type = SPIRAL 27 | 28 | super(TrivialMaze, self).__init__(h, w) 29 | 30 | def generate(self): 31 | """Highest-level method that implements the maze-generating algorithm. 32 | 33 | Returns 34 | ------- 35 | np.array: returned matrix 36 | """ 37 | # create empty grid 38 | grid = np.empty((self.H, self.W), dtype=np.int8) 39 | grid.fill(1) 40 | 41 | if self.maze_type == SERPENTINE: 42 | return self._generate_serpentine_maze(grid) 43 | else: 44 | return self._generate_spiral_maze(grid) 45 | 46 | def _generate_serpentine_maze(self, grid): 47 | """Create a simple maze that snakes around the grid. 48 | This is a unicursal maze (with no dead ends). 49 | 50 | Args: 51 | grid (np.array): maze array 52 | Returns: 53 | np.array: update maze array 54 | """ 55 | vertical_skew = randint(0, 1) 56 | height = grid.shape[0] 57 | width = grid.shape[1] 58 | 59 | if vertical_skew: 60 | for row in range(1, height - 1): 61 | for col in range(1, width - 1, 2): 62 | grid[(row, col)] = 0 63 | # add minor passages 64 | for col in range(2, width - 1, 4): 65 | grid[(1, col)] = 0 66 | for col in range(4, width - 1, 4): 67 | grid[(height - 2, col)] = 0 68 | else: 69 | for row in range(1, height - 1, 2): 70 | for col in range(1, width - 1): 71 | grid[(row, col)] = 0 72 | # add minor passages 73 | for row in range(2, height - 1, 4): 74 | grid[(row, 1)] = 0 75 | for row in range(4, height - 1, 4): 76 | grid[(row, width - 2)] = 0 77 | 78 | return grid 79 | 80 | def _generate_spiral_maze(self, grid): 81 | """Create a simple maze that has a spiral path from 82 | start to end. This is a unicursal maze (with no dead ends). 83 | 84 | Args: 85 | grid (np.array): maze array 86 | Returns: 87 | np.array: update maze array 88 | """ 89 | clockwise = randint(0, 1) 90 | # define the directions you will turn 91 | if clockwise == 1: 92 | directions = [(0, -2), (2, 0), (0, 2), (-2, 0)] 93 | else: 94 | directions = [(-2, 0), (0, 2), (2, 0), (0, -2)] 95 | 96 | current = (1, 1) 97 | grid[current] = 0 98 | next_dir = 0 99 | while True: 100 | next_cell = self._move(current, directions[next_dir]) 101 | ns = self._find_neighbors(current[0], current[1], grid, True) 102 | if next_cell in ns: 103 | grid[self._midpoint(current, next_cell)] = 0 104 | grid[next_cell] = 0 105 | current = next_cell 106 | elif len(ns) == 0: 107 | break 108 | else: 109 | next_dir = (next_dir + 1) % 4 110 | 111 | return grid 112 | 113 | def _midpoint(self, a, b): 114 | """Find the wall cell between to passage cells. 115 | 116 | Args: 117 | a (tuple): first cell position 118 | b (tuple): second cell position 119 | Returns: 120 | tuple: cell position at the half-way point between the other two 121 | """ 122 | return (a[0] + b[0]) // 2, (a[1] + b[1]) // 2 123 | 124 | def _move(self, start, direction): 125 | """Convolve a position tuple with a direction tuple to generate a new position. 126 | 127 | Args: 128 | start (tuple): first cell position 129 | direction (tuple): direction to travel from start 130 | Returns: 131 | tuple: end result of start position plus direction vector 132 | """ 133 | return (start[0] + direction[0], start[1] + direction[1]) 134 | -------------------------------------------------------------------------------- /test/test_transmuters.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import numpy as np 4 | 5 | from mazelib.generate.Prims import Prims 6 | from mazelib.generate.TrivialMaze import TrivialMaze 7 | from mazelib.mazelib import Maze 8 | from mazelib.transmute.CuldeSacFiller import CuldeSacFiller 9 | from mazelib.transmute.DeadEndFiller import DeadEndFiller 10 | from mazelib.transmute.Perturbation import Perturbation 11 | 12 | 13 | class SolversTest(unittest.TestCase): 14 | def _example_cul_de_sac_maze(self): 15 | """Helper method to generate a super-simple little maze with a loop in it. 16 | 17 | ####### 18 | # 19 | # # # # 20 | # # # 21 | # ##### 22 | # 23 | ####### 24 | """ 25 | g = np.ones((7, 7), dtype=np.int8) 26 | g[1] = [1, 0, 0, 0, 0, 0, 1] 27 | g[2] = [1, 0, 1, 0, 1, 0, 1] 28 | g[3] = [1, 0, 1, 0, 0, 0, 1] 29 | g[4] = [1, 0, 1, 1, 1, 1, 1] 30 | g[5] = [1, 0, 0, 0, 0, 0, 1] 31 | 32 | return g 33 | 34 | def test_cul_de_sac_filler(self): 35 | """Test the CuldeSacFiller leaves the maze in a solvable state.""" 36 | m = Maze(8765) 37 | m.generator = Prims(3, 3) 38 | m.generate() 39 | m.grid = self._example_cul_de_sac_maze() 40 | 41 | assert m.grid[(1, 5)] == 0 42 | 43 | m.transmuters = [CuldeSacFiller()] 44 | m.transmute() 45 | 46 | assert m.grid[(1, 5)] == 1 47 | assert boundary_is_solid(m.grid) 48 | assert all_corners_complete(m.grid) 49 | 50 | def test_dead_end_filler(self): 51 | """Test the CuldeSacFiller and DeadEndFiller leave the maze in a solvable state.""" 52 | for i in range(10): 53 | m = Maze(678 + i) 54 | m.generator = Prims(3, 3) 55 | m.generate() 56 | m.start = (1, 0) 57 | m.end = (5, 4) 58 | m.grid = self._example_cul_de_sac_maze() 59 | 60 | assert m.grid[(1, 5)] == 0 61 | assert m.grid[(1, 2)] == 0 62 | assert m.grid[(3, 3)] == 0 63 | 64 | m.transmuters = [CuldeSacFiller(), DeadEndFiller(99)] 65 | m.transmute() 66 | 67 | assert m.grid[(1, 5)] == 1 68 | assert m.grid[(1, 2)] == 1 69 | assert m.grid[(3, 3)] == 1 70 | 71 | assert boundary_is_solid(m.grid) 72 | assert all_corners_complete(m.grid) 73 | 74 | def test_perturbation(self): 75 | """Test the Perturbation algorithm leaves the maze in a solvable state.""" 76 | for i in range(10): 77 | m = Maze(9087 + i) 78 | m.generator = TrivialMaze(4, 5) 79 | m.generate() 80 | 81 | m.transmuters = [Perturbation()] 82 | m.transmute() 83 | 84 | assert boundary_is_solid(m.grid) 85 | assert all_passages_open(m.grid) 86 | assert all_corners_complete(m.grid) 87 | 88 | 89 | def boundary_is_solid(grid): 90 | """Helper method to test of the maze is sane. 91 | Algorithms should generate a maze with a solid boundary of walls. 92 | 93 | Args: 94 | grid (np.array): maze array 95 | Returns: 96 | boolean: is the maze boundary solid? 97 | """ 98 | # first row 99 | for c in grid[0]: 100 | if c == 0: 101 | return False 102 | 103 | # other rows 104 | for row in grid[1:-1]: 105 | if row[0] == 0 or row[-1] == 0: 106 | return False 107 | 108 | # last row 109 | for c in grid[grid.shape[0] - 1]: 110 | if c == 0: 111 | return False 112 | 113 | return True 114 | 115 | 116 | def all_passages_open(grid): 117 | """Helper method to test of the maze is sane. 118 | All of the (odd, odd) grid cells in a maze should be passages. 119 | 120 | Args: 121 | grid (np.array): maze array 122 | Returns: 123 | booean: Are all the odd/odd grid cells open? 124 | """ 125 | H, W = grid.shape 126 | 127 | for r in range(1, H, 2): 128 | for c in range(1, W, 2): 129 | if grid[r, c] == 1: 130 | return False 131 | 132 | return True 133 | 134 | 135 | def all_corners_complete(grid): 136 | """Helper method to test of the maze is sane. 137 | All of the (even, even) grid cells in a maze should be walls. 138 | 139 | Args: 140 | grid (np.array): maze array 141 | Returns: 142 | boolean: Are all of the grid corners solid? 143 | """ 144 | H, W = grid.shape 145 | 146 | for r in range(2, H, 2): 147 | for c in range(2, W, 2): 148 | if grid[r, c] == 0: 149 | return False 150 | 151 | return True 152 | 153 | 154 | if __name__ == "__main__": 155 | unittest.main(argv=["first-arg-is-ignored"], exit=False) 156 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0", "wheel", "Cython"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "mazelib" 7 | version = "0.9.16" 8 | authors = [{ name="John Stilley" }] 9 | description = "A Python API for creating and solving mazes." 10 | readme = "README.md" 11 | requires-python = ">=3.7.0, <3.14" 12 | dependencies = [ 13 | "cython", 14 | "numpy", 15 | ] 16 | license = { file="LICENSE" } 17 | classifiers = [ 18 | "Development Status :: 4 - Beta", 19 | "Topic :: Software Development :: Libraries :: Python Modules", 20 | "Topic :: Scientific/Engineering :: Mathematics", 21 | "Topic :: Games/Entertainment :: Puzzle Games", 22 | "License :: OSI Approved :: MIT License", 23 | "Operating System :: OS Independent", 24 | "Programming Language :: Python :: 3.8", 25 | "Programming Language :: Python :: 3.9", 26 | "Programming Language :: Python :: 3.10", 27 | "Programming Language :: Python :: 3.11", 28 | "Programming Language :: Python :: 3.12", 29 | "Programming Language :: Python :: 3.13", 30 | "Natural Language :: English", 31 | ] 32 | 33 | [project.urls] 34 | "Homepage" = "https://github.com/john-science/mazelib" 35 | "Bug Tracker" = "https://github.com/john-science/mazelib/issues" 36 | 37 | 38 | [project.optional-dependencies] 39 | dev = [ 40 | "black == 24.10.0", 41 | "ruff == 0.7.4", 42 | "toml == 0.10.2", 43 | "twine == 4.0.2", 44 | ] 45 | pytest = [ 46 | "pytest", 47 | "pytest-cov", 48 | ] 49 | 50 | [tool.setuptools.package-data] 51 | myModule = ["mazelib/generate/*.pxd", "mazelib/solve/*.pxd", "mazelib/transmute/*.pxd"] 52 | 53 | [tool.distutils.bdist_wheel] 54 | universal = true 55 | 56 | [tool.setuptools.dynamic] 57 | readme = {file = ["README.md"]} 58 | 59 | 60 | ####################################################################### 61 | # RUFF CONFIG # 62 | ####################################################################### 63 | [tool.ruff] 64 | # This is the exact version of Ruff we use. 65 | required-version = "0.7.4" 66 | 67 | # Assume Python 3.13 68 | target-version = "py313" 69 | 70 | # Setting line-length to 100 (though blacks default is 88) 71 | line-length = 120 72 | 73 | # Exclude a variety of commonly ignored directories. 74 | exclude = [ 75 | ".bzr", 76 | ".direnv", 77 | ".eggs", 78 | ".git", 79 | ".git-rewrite", 80 | ".hg", 81 | ".mypy_cache", 82 | ".nox", 83 | ".pants.d", 84 | ".pytype", 85 | ".ruff_cache", 86 | ".svn", 87 | ".tox", 88 | ".venv", 89 | "__pycache__", 90 | "__pypackages__", 91 | "_build", 92 | "buck-out", 93 | "build", 94 | "dist", 95 | "node_modules", 96 | "venv", 97 | ] 98 | 99 | [tool.ruff.lint] 100 | # Enable pycodestyle (E) and Pyflakes (F) codes by default. 101 | # D - NumPy docstring rules 102 | # N801 - Class name should use CapWords convention 103 | # SIM - code simplification rules 104 | # TID - tidy imports 105 | select = ["E", "F", "D", "N801", "SIM", "TID"] 106 | 107 | # Ruff rules we ignore (for now) because they are not 100% automatable 108 | # 109 | # D100 - Missing docstring in public module 110 | # D101 - Missing docstring in public class 111 | # D102 - Missing docstring in public method 112 | # D103 - Missing docstring in public function 113 | # D106 - Missing docstring in public nested class 114 | # D401 - First line of docstring should be in imperative mood 115 | # D404 - First word of the docstring should not be "This" 116 | # SIM102 - Use a single if statement instead of nested if statements 117 | # SIM105 - Use contextlib.suppress({exception}) instead of try-except-pass 118 | # SIM108 - Use ternary operator {contents} instead of if-else-block 119 | # SIM114 - Combine if branches using logical or operator 120 | # SIM115 - Use context handler for opening files 121 | # SIM117 - Use a single with statement with multiple contexts instead of nested with statements 122 | 123 | # Ruff rules we ignore because we don't want them 124 | # 125 | # D105 - we don't need to document well-known magic methods 126 | # D205 - 1 blank line required between summary line and description 127 | # E731 - we can use lambdas however we want 128 | # RUF100 - no unused noqa statements (not consistent enough yet) 129 | # SIM118 - this does not work where we overload the .keys() method 130 | # 131 | ignore = ["D100", "D101", "D102", "D103", "D105", "D106", "D205", "D401", "D404", "E731", "E741", "RUF100", "SIM102", "SIM105", "SIM108", "SIM110", "SIM114", "SIM115", "SIM116", "SIM117", "SIM118"] 132 | 133 | [tool.ruff.lint.per-file-ignores] 134 | # D1XX - enforces writing docstrings 135 | # E741 - ambiguous variable name 136 | # N - We have our own naming conventions for unit tests. 137 | # SLF001 - private member access 138 | "*/tests/*" = ["D1", "E741", "N", "SLF001"] 139 | 140 | [tool.ruff.lint.flake8-tidy-imports] 141 | ban-relative-imports = "all" 142 | 143 | [tool.ruff.lint.pydocstyle] 144 | convention = "numpy" 145 | 146 | -------------------------------------------------------------------------------- /mazelib/generate/HuntAndKill.py: -------------------------------------------------------------------------------- 1 | from random import choice, randrange 2 | import numpy as np 3 | 4 | # If the code is not Cython-compiled, we need to add some imports. 5 | from cython import compiled 6 | 7 | if not compiled: 8 | from mazelib.generate.MazeGenAlgo import MazeGenAlgo 9 | 10 | RANDOM = 1 11 | SERPENTINE = 2 12 | 13 | 14 | class HuntAndKill(MazeGenAlgo): 15 | """ 16 | The Hunt-and-Kill maze-generating algorithm. 17 | 18 | 1. Randomly choose a starting cell. 19 | 2. Perform a random walk from the current cel, carving passages to unvisited neighbors, 20 | until the current cell has no unvisited neighbors. 21 | 3. Select a new grid cell; if it has been visited, walk from it. 22 | 4. Repeat steps 2 and 3 a sufficient number of times that there the probability of a cell 23 | not being visited is extremely small. 24 | 25 | In this implementation of Hunt-and-kill there are two different ways to select a new grid cell in step 2. The first 26 | is serpentine through the grid (the classic solution), the second is to randomly select a new cell enough times that 27 | the probability of an unexplored cell is very, very low. The second option includes a small amount of risk, but it 28 | creates a more interesting, harder maze. 29 | """ 30 | 31 | def __init__(self, w, h, hunt_order="random"): 32 | super(HuntAndKill, self).__init__(w, h) 33 | 34 | # the user can define what order to hunt for the next cell in 35 | if hunt_order.lower().strip() == "serpentine": 36 | self.ho = SERPENTINE 37 | else: 38 | self.ho = RANDOM 39 | 40 | def generate(self): 41 | """Highest-level method that implements the maze-generating algorithm. 42 | 43 | Returns 44 | ------- 45 | np.array: returned matrix 46 | """ 47 | # create empty grid 48 | grid = np.empty((self.H, self.W), dtype=np.int8) 49 | grid.fill(1) 50 | 51 | # find an arbitrary starting position 52 | current_row, current_col = (randrange(1, self.H, 2), randrange(1, self.W, 2)) 53 | grid[current_row][current_col] = 0 54 | 55 | # perform many random walks, to fill the maze 56 | num_trials = 0 57 | while (current_row, current_col) != (-1, -1): 58 | self._walk(grid, current_row, current_col) 59 | current_row, current_col = self._hunt(grid, num_trials) 60 | num_trials += 1 61 | 62 | return grid 63 | 64 | def _walk(self, grid, row, col): 65 | """This is a standard random walk. It must start from a visited cell. 66 | And it completes when the current cell has no unvisited neighbors. 67 | 68 | Args: 69 | grid (np.array): maze array 70 | row (int): row index 71 | col (int): col index 72 | Returns: None 73 | """ 74 | if grid[row][col] == 0: 75 | this_row = row 76 | this_col = col 77 | unvisited_neighbors = self._find_neighbors(this_row, this_col, grid, True) 78 | 79 | while len(unvisited_neighbors) > 0: 80 | neighbor = choice(unvisited_neighbors) 81 | grid[neighbor[0]][neighbor[1]] = 0 82 | grid[(neighbor[0] + this_row) // 2][(neighbor[1] + this_col) // 2] = 0 83 | this_row, this_col = neighbor 84 | unvisited_neighbors = self._find_neighbors( 85 | this_row, this_col, grid, True 86 | ) 87 | 88 | def _hunt(self, grid, count): 89 | """Based on how this algorithm was configured, choose hunt for the next starting point. 90 | 91 | Args: 92 | grid (np.array): maze array 93 | count (int): how long to iterate 94 | Returns: 95 | tuple: position of next cell 96 | """ 97 | if self.ho == SERPENTINE: 98 | return self._hunt_serpentine(grid, count) 99 | else: 100 | return self._hunt_random(grid, count) 101 | 102 | def _hunt_random(self, grid, count): 103 | """Select the next cell to walk from, randomly. 104 | 105 | Args: 106 | grid (np.array): maze array 107 | count (int): row index 108 | Returns: 109 | tuple: position of next cell 110 | """ 111 | if count >= (self.H * self.W): 112 | return (-1, -1) 113 | 114 | return (randrange(1, self.H, 2), randrange(1, self.W, 2)) 115 | 116 | def _hunt_serpentine(self, grid, count): 117 | """Select the next cell to walk from by cycling through every grid cell in order. 118 | 119 | Args: 120 | grid (np.array): maze array 121 | count (int): how long to iterate 122 | Returns: 123 | tuple: position of next cell 124 | """ 125 | row, col = (1, 1) 126 | found = False 127 | 128 | while not found: 129 | col = col + 2 130 | if col > (self.W - 2): 131 | row += 2 132 | col = 1 133 | if row > (self.H - 2): 134 | return (-1, -1) 135 | 136 | if ( 137 | grid[row][col] == 0 138 | and len(self._find_neighbors(row, col, grid, True)) > 0 139 | ): 140 | found = True 141 | 142 | return (row, col) 143 | -------------------------------------------------------------------------------- /mazelib/solve/Collision.py: -------------------------------------------------------------------------------- 1 | # If the code is not Cython-compiled, we need to add some imports. 2 | from cython import compiled 3 | 4 | if not compiled: 5 | from mazelib.solve.MazeSolveAlgo import MazeSolveAlgo 6 | 7 | # CONSTANTS 8 | END = (-999, -9) 9 | DEAD_END = (-9, -999) 10 | 11 | 12 | class Collision(MazeSolveAlgo): 13 | """ 14 | The Collision maze solving algorithm. 15 | 16 | 1. step through the maze, flooding all directions equally 17 | 2. if two flood paths meet, create a wall where they meet 18 | 3. fill in all dead ends 19 | 4. repeat until there are no more collisions 20 | """ 21 | 22 | def _solve(self): 23 | """Solve a maze by sending out robots in all directions at the same speed, 24 | More robots are created at each new intersections. 25 | And all robots that collide, stop running. 26 | 27 | Returns 28 | ------- 29 | list: all the solutions what were found 30 | """ 31 | # deal with the case where the start is on the edge 32 | start = self.start 33 | if self._on_edge(self.start): 34 | start = self._push_edge(self.start) 35 | 36 | # flood the maze twice, and compare the results 37 | paths = self._flood_maze(start) 38 | temp_paths = self._flood_maze(start) 39 | diff = list(set(map(tuple, paths)) - set(map(tuple, temp_paths))) 40 | 41 | # re-flood the maze until there are no more dead ends 42 | while diff: 43 | paths = temp_paths 44 | temp_paths = self._flood_maze(start) 45 | diff = list(set(map(tuple, paths)) - set(map(tuple, temp_paths))) 46 | 47 | paths = self._fix_entrances(paths) 48 | 49 | return paths 50 | 51 | def _flood_maze(self, start): 52 | """From the start, flood the maze one cell at a time, 53 | keep track of where the water flows as paths through the maze. 54 | 55 | Args: 56 | start (tuple): position to start studying from 57 | Returns: 58 | list: all the paths taken, flooding from the start location 59 | """ 60 | paths = self._one_time_step([[start]]) 61 | temp_paths = paths 62 | 63 | while temp_paths is not None: 64 | paths = temp_paths 65 | temp_paths = self._one_time_step(paths) 66 | 67 | return paths 68 | 69 | def _one_time_step(self, paths): 70 | """Move all open paths forward one grid cell. 71 | 72 | Args: 73 | paths (list): all the currently-running robots 74 | Returns: 75 | list: the next step for all robots that are left 76 | """ 77 | temp_paths = [] 78 | step_made = False 79 | 80 | for path in paths: 81 | if path is None or path[-1] == DEAD_END: 82 | continue 83 | elif path[-1] == END: 84 | temp_paths.append(path) 85 | continue 86 | 87 | ns = self._find_unblocked_neighbors(path[-1]) 88 | if len(path) > 2: 89 | if path[-3] in ns: 90 | ns.remove(path[-3]) 91 | 92 | if len(ns) == 0: 93 | temp_paths.append(path + [DEAD_END]) 94 | step_made = True 95 | else: 96 | step_made = True 97 | for neighbor in ns: 98 | mid = self._midpoint(path[-1], neighbor) 99 | if self._within_one(neighbor, self.end): 100 | temp_paths.append(path + [mid, neighbor, END]) 101 | else: 102 | temp_paths.append(path + [mid, neighbor]) 103 | 104 | if not step_made: 105 | return None 106 | 107 | # fix collisions 108 | temp_paths = self._fix_collisions(temp_paths) 109 | 110 | return temp_paths 111 | 112 | def _fix_collisions(self, paths): 113 | """Look through paths for collisions. 114 | If a collision exists, build a wall in the maze at that point. 115 | 116 | Args: 117 | paths (list): all the currently-running robots 118 | Returns: 119 | list: all working robot paths that are left 120 | """ 121 | N = len(paths) 122 | 123 | for i in range(N - 1): 124 | if paths[i][-1] in [DEAD_END, END]: 125 | continue 126 | for j in range(i + 1, N): 127 | if paths[j][-1] in [DEAD_END, END]: 128 | continue 129 | if paths[i][-1] == paths[j][-1]: 130 | row, col = paths[i][-1] 131 | self.grid[row, col] = 1 132 | paths[i][-1] = None 133 | paths[j][-1] = None 134 | 135 | return paths 136 | 137 | def _fix_entrances(self, paths): 138 | """Ensure the start and end are appropriately placed in the solution. 139 | 140 | Args: 141 | paths (list): all the currently-running robots 142 | Returns: 143 | list: spruced-up solution paths 144 | """ 145 | # Filter out paths ending in 'dead_end' 146 | # (also: remove 'end' from solution paths) 147 | paths = [p[:-1] for p in paths if p[-1] != DEAD_END] 148 | 149 | # if start not on edge, remove first position in all paths 150 | if not self._on_edge(self.start): 151 | paths = [p[1:] for p in paths] 152 | 153 | # if end not on edge, remove last position in all paths 154 | if not self._on_edge(self.end): 155 | paths = [p[:-1] for p in paths] 156 | 157 | return paths 158 | -------------------------------------------------------------------------------- /mazelib/solve/Tremaux.py: -------------------------------------------------------------------------------- 1 | from random import choice 2 | 3 | # If the code is not Cython-compiled, we need to add some imports. 4 | from cython import compiled 5 | 6 | if not compiled: 7 | from mazelib.solve.MazeSolveAlgo import MazeSolveAlgo 8 | 9 | 10 | class Tremaux(MazeSolveAlgo): 11 | """ 12 | The Tremaux maze solving algorithm. 13 | 14 | 0. Every time you visit a cell, mark it once. 15 | 1. When you hit a dead end, turn around and go back. 16 | 2. When you hit a junction you haven't visited, pick a new passage at random. 17 | 3. If you're walking down a new passage and hit a junction you have visited, 18 | treat it like a dead end and go back. 19 | 4. If walking down a passage you have visited before (i.e. marked once) and you hit a junction, 20 | take any new passage available, otherwise take an old passage (i.e. marked once). 21 | 5. When you finally reach the end, follow cells marked exactly once back to the start. 22 | 6. If the Maze has no solution, you'll find yourself at the start with all cells marked twice. 23 | 24 | Results 25 | 26 | Finds one non-optimal solution. 27 | Works against imperfect mazes. 28 | 29 | Notes 30 | ----- 31 | This Maze-solving method is designed to be used by a human inside the Maze. 32 | """ 33 | 34 | def __init__(self): 35 | self.visited_cells = {} 36 | 37 | def _solve(self): 38 | """Implementing the algorithm. 39 | 40 | Return: 41 | list: a single maze solution 42 | """ 43 | self.visited_cells = {} 44 | solution = [] 45 | 46 | # a first move has to be made 47 | current = self.start 48 | solution.append(current) 49 | self._visit(current) 50 | 51 | # if you're on the edge, push it in one 52 | if self._on_edge(self.start): 53 | current = self._push_edge(self.start) 54 | solution.append(current) 55 | self._visit(current) 56 | 57 | # pick a random neighbor using Tremaux logic and travel to it, until you're at the end 58 | while not self._within_one(solution[-1], self.end): 59 | # find the neighbors of the current cell 60 | ns = self._find_unblocked_neighbors(solution[-1]) 61 | 62 | # pick the next cell based on the Tremaux logic 63 | nxt = self._what_next(ns, solution) 64 | 65 | # visit the new cell 66 | solution.append(self._midpoint(solution[-1], nxt)) 67 | solution.append(nxt) 68 | self._visit(nxt) 69 | 70 | return [solution] 71 | 72 | def _visit(self, cell): 73 | """Increment the number of times a cell has been visited. 74 | 75 | Args: 76 | cell (tuple): cell of interest 77 | Returns: None 78 | """ 79 | if cell not in self.visited_cells: 80 | self.visited_cells[cell] = 0 81 | 82 | self.visited_cells[cell] += 1 83 | 84 | def _get_visit_count(self, cell): 85 | """Count how many times has a cell been visited. 86 | 87 | Args: 88 | cell (tuple): cell of interest 89 | Returns: 90 | int: How many times has that cell been visited? 91 | """ 92 | if cell not in self.visited_cells: 93 | return 0 94 | else: 95 | return self.visited_cells[cell] if self.visited_cells[cell] < 3 else 2 96 | 97 | def _what_next(self, ns, solution): 98 | """Find the cell to move to next, based on the Tremaux logic. 99 | 100 | 1. When you hit a dead end, turn around and go back. 101 | 2. When you hit a junction you haven't visited, pick a new passage at random. 102 | 3. If you're walking down a new passage and hit a junction you have visited, 103 | treat it like a dead end and go back. 104 | 4. If walking down a passage you have visited before (i.e. marked once) and you hit a junction, 105 | take any new passage available, otherwise take an old passage (i.e. marked once). 106 | 107 | Args: 108 | ns (list): neighboring cells to choose next move from 109 | solution (list): the path we have taken so far 110 | Returns: 111 | tuple: the cell we want to move to next 112 | """ 113 | # handle the easy scenario (and let this throw an error if ns is empty) 114 | if len(ns) <= 1: 115 | return ns[0] 116 | 117 | # organize the neighbors by their visit count 118 | visit_counts = {} 119 | for neighbor in ns: 120 | visit_count = self._get_visit_count(neighbor) 121 | if visit_count not in visit_counts: 122 | visit_counts[visit_count] = [] 123 | visit_counts[visit_count].append(neighbor) 124 | 125 | # fulfill the Tremaux rules, using visit counts 126 | if 0 in visit_counts: 127 | # handle the case where we have no choice where to go 128 | return choice(visit_counts[0]) 129 | elif 1 in visit_counts: 130 | # try not to backtrack, if you can 131 | if ( 132 | len(visit_counts[1]) > 1 133 | and len(solution) > 2 134 | and solution[-3] in visit_counts[1] 135 | ): 136 | visit_counts[1].remove(solution[-3]) 137 | return choice(visit_counts[1]) 138 | else: 139 | # try not to backtrack, if you can 140 | if ( 141 | len(visit_counts[2]) > 1 142 | and len(solution) > 2 143 | and solution[-3] in visit_counts[2] 144 | ): 145 | visit_counts[2].remove(solution[-3]) 146 | return choice(visit_counts[2]) 147 | -------------------------------------------------------------------------------- /mazelib/transmute/Perturbation.py: -------------------------------------------------------------------------------- 1 | from random import choice, randrange 2 | 3 | # If the code is not Cython-compiled, we need to add some imports. 4 | from cython import compiled 5 | 6 | if not compiled: 7 | from mazelib.transmute.MazeTransmuteAlgo import MazeTransmuteAlgo 8 | 9 | 10 | class Perturbation(MazeTransmuteAlgo): 11 | """ 12 | The Perturbation maze transmutation algorithm. 13 | 14 | 1. Start with a complete, valid maze. 15 | 2. Add a small number of random walls, blocking current passages. 16 | 3. Go through the maze and reconnect all passages that are not currently open, 17 | by randomly opening walls. 18 | 4. Repeat steps 3 and 4 a prescribed number of times. 19 | 20 | Optional Parameters: 21 | 22 | new_walls: Integer [1, ...) 23 | The number of randomly positioned new walls you create throughout the maze. (default 1) 24 | repeat: Integer [1, ...) 25 | The number of times sets of new walls will be added to the maze; 26 | the maze being fixed after each set. (default 1) 27 | """ 28 | 29 | def __init__(self, repeat=1, new_walls=1): 30 | self.repeat = repeat 31 | self.new_walls = new_walls 32 | super(Perturbation, self).__init__() 33 | 34 | def _transmute(self): 35 | """Primary method to slightly perturb the maze a set number of times.""" 36 | for i in range(self.repeat): 37 | # Add a small number of random walls, blocking current passages 38 | for j in range(self.new_walls): 39 | self._add_a_random_wall() 40 | 41 | # re-fix the maze 42 | self._reconnect_maze() 43 | 44 | def _add_a_random_wall(self): 45 | """Add a single wall randomly within the maze.""" 46 | limit = 2 * self.grid.shape[0] * self.grid.shape[1] 47 | tries = 0 48 | 49 | found = False 50 | while not found: 51 | row = randrange(1, self.grid.shape[0] - 1) 52 | if row % 2 == 0: 53 | col = randrange(1, self.grid.shape[1] - 1, 2) 54 | else: 55 | col = randrange(2, self.grid.shape[1] - 1, 2) 56 | 57 | if self.grid[row][col] == 0: 58 | found = True 59 | self.grid[row][col] = 1 60 | else: 61 | tries += 1 62 | 63 | # Emergency Catch: in case all walls in the maze are filled 64 | if tries > limit: 65 | return 66 | 67 | def _reconnect_maze(self): 68 | """If a maze is not fully connected, open up walls until it is.""" 69 | passages = self._find_all_passages() 70 | self._fix_disjoint_passages(passages) 71 | 72 | def _find_all_passages(self): 73 | """Place all connected passage cells into a set. Disjoint passages will be in different sets. 74 | 75 | Returns 76 | ------- 77 | list: all of the non-connected paths in the maze 78 | """ 79 | passages = [] 80 | 81 | # go through all cells in the maze 82 | for r in range(1, self.grid.shape[0], 2): 83 | for c in range(1, self.grid.shape[1], 2): 84 | ns = self._find_unblocked_neighbors((r, c)) 85 | current = set(ns + [(r, c)]) 86 | 87 | # determine which passage(s) the current neighbors belong in 88 | found = False 89 | for i, passage in enumerate(passages): 90 | intersect = current.intersection(passage) 91 | if len(intersect) > 0: 92 | passages[i] = passages[i].union(current) 93 | found = True 94 | break 95 | 96 | # the current neighbors might be a disjoint set 97 | if not found: 98 | passages.append(current) 99 | 100 | return self._join_intersecting_sets(passages) 101 | 102 | def _fix_disjoint_passages(self, disjoint_passages): 103 | """All passages in a maze should be connected. 104 | 105 | Args: 106 | disjoint_passages (list): presumably non-connected paths in the maze 107 | """ 108 | while len(disjoint_passages) > 1: 109 | found = False 110 | while not found: 111 | # randomly select a cell in the first passage 112 | cell = choice(list(disjoint_passages[0])) 113 | neighbors = self._find_neighbors(cell[0], cell[1]) 114 | # determine if that cell has a neighbor in any other passage 115 | for passage in disjoint_passages[1:]: 116 | intersect = [c for c in neighbors if c in passage] 117 | # if so, remove the dividing wall, combine the two passages 118 | if len(intersect) > 0: 119 | mid = self._midpoint(intersect[0], cell) 120 | self.grid[mid[0]][mid[1]] = 0 121 | disjoint_passages[0] = disjoint_passages[0].union(passage) 122 | disjoint_passages.remove(passage) 123 | found = True 124 | break 125 | 126 | def _join_intersecting_sets(self, list_of_sets): 127 | """Combine sets that have non-zero intersections. 128 | 129 | Args: 130 | list_of_sets (list): presumably non-connected paths in the maze 131 | Returns: 132 | list: definitely non-connected paths in the maze 133 | """ 134 | for i in range(len(list_of_sets) - 1): 135 | if list_of_sets[i] is None: 136 | continue 137 | 138 | for j in range(i + 1, len(list_of_sets)): 139 | if list_of_sets[j] is None: 140 | continue 141 | 142 | intersect = list_of_sets[i].intersection(list_of_sets[j]) 143 | if len(intersect) > 0: 144 | list_of_sets[i] = list_of_sets[i].union(list_of_sets[j]) 145 | list_of_sets[j] = None 146 | 147 | return list(filter(lambda l: l is not None, list_of_sets)) 148 | -------------------------------------------------------------------------------- /docs/API.md: -------------------------------------------------------------------------------- 1 | # mazelib API 2 | 3 | ##### Go back to the main [README](../README.md) 4 | 5 | The `mazelib` library provides tools for creating and solving 2D mazes in Python. The library includes all of the classic algorithms for creating and solving mazes. Most of these have optional parameters to customize the result. Several more modern methods are also provided, to help expedite practical use-cases. 6 | 7 | The mazelib library supports Python versions 3.4 and up. 8 | 9 | 10 | ## How to Create a Maze 11 | 12 | Let us look at the simplest example: 13 | 14 | from mazelib import Maze 15 | from mazelib.generate.Prims import Prims 16 | 17 | m = Maze() 18 | m.generator = Prims(27, 34) 19 | m.generate() 20 | 21 | First, there was the obligatory import statement, to include mazelib in your Python code: `from mazelib import *`. 22 | 23 | Then, a `Maze` object was created. Maze objects have the following attributes: 24 | 25 | * grid: 2D array of data representing the maze itself 26 | * start: starting location in the maze 27 | * end: exit location in the maze 28 | * generator: algorithm used to generate the maze walls 29 | * solver: algorithm used to solve the maze 30 | * solutions: list of solution paths to the maze 31 | 32 | Finally, an algorithm was selected to generate the maze. In this case, the `Prims` algorithm was used to generate a maze that was 27 rows tall and 34 rows wide. And the Prims algorithm was run. 33 | 34 | A complete listing of available maze-generating algorithms can be found [here](MAZE_GEN_ALGOS.md). 35 | 36 | 37 | ## How to Solve a Maze 38 | 39 | Again, let's look at the simplest example: 40 | 41 | from mazelib.solve.BacktrackingSolver import BacktrackingSolver 42 | m.solver = BacktrackingSolver() 43 | m.generate_entrances() 44 | m.solve() 45 | 46 | The `BacktrackingSolver` algorithm was chosen to solve the maze. But first, entrances to the maze had to be randomly generated using the helper method `generate_entrances()`. If you prefer, you can manually set the entrances using: 47 | 48 | m.start = (1, 1) 49 | m.end = (5, 5) 50 | 51 | By default, entrances will be generated on the outer edge of the maze. However, you can set if each entrance will be on the outer edge or inside the maze using the optional inputs: 52 | 53 | m.generate_entrances(False, True) 54 | m.generate_entrances(start_outer=False, end_outer=False) 55 | 56 | Finally, the maze `m` was solved for the given entrances, using the `BacktrackingSolver` algorithm. 57 | 58 | A complete listing of available maze-solving algorithms can be found [here](MAZE_SOLVE_ALGOS.md). 59 | 60 | 61 | ## Fixing the Random Seed 62 | 63 | At any point you want, you can set / reset the random seeds for all the libraries used in this project using: 64 | 65 | Maze.set_seed(123) 66 | 67 | Or, when you create the `Maze` object in the first place: 68 | 69 | m = Maze(345) 70 | 71 | 72 | ## Optional: Transmuting a Maze 73 | 74 | Many classic Maze-Solving algorithms boil down to simplifying the maze, and then solving with some other algorithm. For instance, say you have a maze with a loop in it: 75 | 76 | # force-create a maze with a loop in it 77 | import numpy as np 78 | g = np.ones((7, 7), dtype=np.int8) 79 | g[1] = [0,0,0,0,0,0,1] 80 | g[2] = [1,0,1,0,1,0,1] 81 | g[3] = [1,0,1,0,0,0,1] 82 | g[4] = [1,0,1,1,1,1,1] 83 | g[5] = [1,0,0,0,0,0,0] 84 | 85 | from mazelib import Maze 86 | m = Maze() 87 | m.grid = g 88 | print(m) 89 | 90 | ####### 91 | S # 92 | # # # # 93 | # # # 94 | # ##### 95 | # E 96 | ####### 97 | 98 | Let's say we want to find and break all loops in a maze. Who knows why, perhaps our maze-solving algorithm can't handle loops. Or maybe we just don't like them. 99 | 100 | from mazelib.transmute.CuldeSacFiller import CuldeSacFiller 101 | m.transmuters = [CuldeSacFiller()] 102 | m.transmute() 103 | print(m) 104 | 105 | ####### 106 | S ## 107 | # # # # 108 | # # # 109 | # ##### 110 | # E 111 | ####### 112 | 113 | Or perhaps you want to get rid of some dead ends: 114 | 115 | from mazelib.transmute.DeadEndFiller import DeadEndFiller 116 | m.transmuters = [CuldeSacFiller(), DeadEndFiller()] 117 | m.transmute() 118 | print(m) 119 | 120 | ####### 121 | S ## 122 | # # ### 123 | # # ### 124 | # ##### 125 | # E 126 | ####### 127 | 128 | Or you might want to get ri of *all* the dead ends: 129 | 130 | m.transmuters = [CuldeSacFiller(), DeadEndFiller(999)] 131 | m.transmute() 132 | print(m) 133 | 134 | ####### 135 | S ##### 136 | # ##### 137 | # ##### 138 | # ##### 139 | # E 140 | ####### 141 | 142 | 143 | ## Advanced: Controlling Maze Difficulty 144 | 145 | > How do you control the difficulty of the mazes you generate? 146 | 147 | The idea is simple: a number of equally-sized mazes are generated and solved, then these mazes are organized by the length of their shortest solution. To get a very hard maze, just select one of the ones at the end of the list. 148 | 149 | Let us do an example: 150 | 151 | m = Maze() 152 | m.generator = Prims(50, 51) 153 | m.solver = BacktrackingSolver() 154 | m.generate_monte_carlo(100, 10, 1.0) 155 | 156 | The above code will generate 100 different mazes, and for each maze generate 10 different pairs of start/end entrances (on the outermost border of the maze). For each of the 10 pairs of entrances, one will be selected that generates the longest solution. Then the 100 mazes will be organized by the length of their solutions. In this case, the maze with the longest solution, as defined by the `1.0`, will be returned. If you wanted the maze with the shortest, and hence easiest, solution you would put `m.generate_monte_carlo(100, 10, 0.0)`. A value of `0.5` would give you a maze with middle-of-the-road difficulty, and so on. 157 | 158 | This basic implementation of the Monte Carlo method gives you a lot of power to not just generate mazes, but generate mazes with properties you like. 159 | 160 | 161 | ##### Go back to the main [README](../README.md) 162 | -------------------------------------------------------------------------------- /mazelib/solve/ShortestPaths.py: -------------------------------------------------------------------------------- 1 | # If the code is not Cython-compiled, we need to add some imports. 2 | from cython import compiled 3 | 4 | if not compiled: 5 | from mazelib.solve.MazeSolveAlgo import MazeSolveAlgo 6 | 7 | 8 | class ShortestPaths(MazeSolveAlgo): 9 | """ 10 | The Shortest Paths maze solving algorithm. 11 | 12 | 1) create a solution for each starting position 13 | 2) loop through each solution, and find the neighbors of the last element 14 | 3) The first solution to reach the end wins. 15 | 16 | Results: 17 | 18 | The shortest unique solutions. Works against imperfect mazes. 19 | """ 20 | 21 | def _solve(self): 22 | """Bredth-first search solutions to the maze. 23 | 24 | Returns 25 | ------- 26 | list: valid maze solutions 27 | """ 28 | # determine if edge or body entrances 29 | self.start_edge = self._on_edge(self.start) 30 | self.end_edge = self._on_edge(self.end) 31 | 32 | # a first move has to be made 33 | start = self.start 34 | if self.start_edge: 35 | start = self._push_edge(self.start) 36 | 37 | # find the starting positions 38 | start_posis = self._find_unblocked_neighbors(start) 39 | assert len(start_posis) > 0, "Input maze is invalid." 40 | 41 | # 1) create a solution for each starting position 42 | solutions = [] 43 | for sp in start_posis: 44 | if self.start_edge: 45 | solutions.append([start, self._midpoint(start, sp), sp]) 46 | else: 47 | solutions.append([self._midpoint(start, sp), sp]) 48 | 49 | # 2) loop through each solution, and find the neighbors of the last element 50 | num_unfinished = len(solutions) 51 | while num_unfinished > 0: 52 | for s in range(len(solutions)): 53 | if solutions[s][-1] in solutions[s][:-1]: 54 | # stop all solutions that have done a full loop 55 | solutions[s].append(None) 56 | elif self._within_one(solutions[s][-1], self.end): 57 | # stop all solutions that have reached the end 58 | solutions[s].append(None) 59 | elif solutions[s][-1] is not None: 60 | # continue with all un-stopped solutions 61 | if len(solutions[s]) > 1: 62 | # check to see if you've gone past the endpoint 63 | if ( 64 | self._midpoint(solutions[s][-1], solutions[s][-2]) 65 | == self.end 66 | ): 67 | solutions[s].append(None) 68 | continue 69 | 70 | # find all the neighbors of the last cell in the solution 71 | ns = self._find_unblocked_neighbors(solutions[s][-1]) 72 | ns = [n for n in ns if n not in solutions[s][-2:]] 73 | 74 | if len(ns) == 0: 75 | # there are no valid neighbors 76 | solutions[s].append(None) 77 | elif len(ns) == 1: 78 | # there is only one valid neighbor 79 | solutions[s].append(self._midpoint(ns[0], solutions[s][-1])) 80 | solutions[s].append(ns[0]) 81 | else: 82 | # there are 2 or 3 valid neighbors 83 | for j in range(1, len(ns)): 84 | nxt = [self._midpoint(ns[j], solutions[s][-1]), ns[j]] 85 | solutions.append(list(solutions[s]) + nxt) 86 | solutions[s].append(self._midpoint(ns[0], solutions[s][-1])) 87 | solutions[s].append(ns[0]) 88 | 89 | # 3) a solution reaches the end or a dead end when we mark it by appending a None. 90 | num_unfinished = sum( 91 | map(lambda sol: 0 if sol[-1] is None else 1, solutions) 92 | ) 93 | 94 | # 4) clean-up solutions 95 | solutions = self._clean_up(solutions) 96 | 97 | assert len(solutions) > 0 and len(solutions[0]) > 0, "No valid solutions found." 98 | 99 | return solutions 100 | 101 | def _clean_up(self, solutions): 102 | """Cleaning up the solutions in three stages. 103 | 104 | 1) remove incomplete solutions 105 | 2) remove duplicate solutions 106 | 3) order the solutions by length (short to long) 107 | 108 | Args: 109 | solutions (list): collection of maze solutions 110 | Returns: 111 | list: cleaner collection of solutions 112 | """ 113 | # 1) remove incomplete solutions 114 | new_solutions = [] 115 | for sol in solutions: 116 | new_sol = None 117 | if self.end_edge: 118 | last = self._push_edge(self.end) 119 | # remove edge-case end cells 120 | if len(sol) > 2 and self._within_one(sol[-2], self.end): 121 | new_sol = sol[:-1] 122 | elif len(sol) > 2 and self._within_one(sol[-2], last): 123 | new_sol = sol[:-1] + [last] 124 | else: 125 | # remove inside-maze-case end cells 126 | if len(sol) > 2 and self._within_one(sol[-2], self.end): 127 | new_sol = sol[:-1] 128 | 129 | if new_sol: 130 | if new_sol[-1] == self.end: 131 | new_sol = new_sol[:-1] 132 | new_solutions.append(new_sol) 133 | 134 | # 2) remove duplicate solutions 135 | solutions = self._remove_duplicate_sols(new_solutions) 136 | 137 | # order the solutions by length (short to long) 138 | return sorted(solutions, key=len) 139 | 140 | def _remove_duplicate_sols(self, sols): 141 | """Remove duplicate solutions using subsetting. 142 | 143 | Args: 144 | solutions (list): collection of maze solutions 145 | Returns: 146 | list: collection of unique maze solutions 147 | """ 148 | return [list(s) for s in set(map(tuple, sols))] 149 | -------------------------------------------------------------------------------- /mazelib/solve/ShortestPath.py: -------------------------------------------------------------------------------- 1 | # If the code is not Cython-compiled, we need to add some imports. 2 | from cython import compiled 3 | 4 | if not compiled: 5 | from mazelib.solve.MazeSolveAlgo import MazeSolveAlgo 6 | 7 | 8 | class ShortestPath(MazeSolveAlgo): 9 | """ 10 | The Shortest Path maze solving algorithm. 11 | 12 | 1) create a solution for each starting position 13 | 2) loop through each solution, and find the neighbors of the last element 14 | 3) a solution reaches the end or a dead end when we mark it by appending a None. 15 | 4) clean-up solutions 16 | 17 | Results 18 | 19 | Find all unique solutions. Works against imperfect mazes. 20 | """ 21 | 22 | def _solve(self): 23 | """Bredth-first search solution to the maze. 24 | 25 | Returns 26 | ------- 27 | list: valid maze solutions 28 | """ 29 | # determine if edge or body entrances 30 | self.start_edge = self._on_edge(self.start) 31 | self.end_edge = self._on_edge(self.start) 32 | 33 | # a first move has to be made 34 | start = self.start 35 | if self.start_edge: 36 | start = self._push_edge(self.start) 37 | 38 | # find the starting positions 39 | start_posis = self._find_unblocked_neighbors(start) 40 | assert len(start_posis) > 0, "Input maze is invalid." 41 | 42 | # 1) create a solution for each starting position 43 | solutions = [] 44 | for sp in start_posis: 45 | if self.start_edge: 46 | solutions.append([start, self._midpoint(start, sp), sp]) 47 | else: 48 | solutions.append([self._midpoint(start, sp), sp]) 49 | 50 | # 2) loop through each solution, and find the neighbors of the last element 51 | num_unfinished = len(solutions) 52 | while num_unfinished > 0: 53 | for s in range(len(solutions)): 54 | if solutions[s][-1] in solutions[s][:-1]: 55 | # stop all solutions that have done a full loop 56 | solutions[s].append(None) 57 | elif self._within_one(solutions[s][-1], self.end): 58 | # stop all solutions that have reached the end 59 | if not self._on_edge(self.end): 60 | # fix solution so it doesn't overlap endpoints 61 | solutions[s] = solutions[s][:-1] 62 | return self._clean_up([solutions[s]]) 63 | elif solutions[s][-1] is not None: 64 | # continue with all un-stopped solutions 65 | if len(solutions[s]) > 1: 66 | # check to see if you've gone past the endpoint 67 | if ( 68 | self._midpoint(solutions[s][-1], solutions[s][-2]) 69 | == self.end 70 | ): 71 | return self._clean_up([solutions[s][:-1]]) 72 | 73 | # find all the neighbors of the last cell in the solution 74 | ns = self._find_unblocked_neighbors(solutions[s][-1]) 75 | ns = [n for n in ns if n not in solutions[s]] 76 | 77 | if len(ns) == 0: 78 | # there are no valid neighbors 79 | solutions[s].append(None) 80 | elif len(ns) == 1: 81 | # there is only one valid neighbor 82 | solutions[s].append(self._midpoint(ns[0], solutions[s][-1])) 83 | solutions[s].append(ns[0]) 84 | else: 85 | # there are 2 or 3 valid neighbors 86 | for j in range(1, len(ns)): 87 | nxt = [self._midpoint(ns[j], solutions[s][-1]), ns[j]] 88 | solutions.append(list(solutions[s]) + nxt) 89 | solutions[s].append(self._midpoint(ns[0], solutions[s][-1])) 90 | solutions[s].append(ns[0]) 91 | 92 | # 3) a solution reaches the end or a dead end when we mark it by appending a None. 93 | num_unfinished = sum( 94 | map(lambda sol: 0 if sol[-1] is None else 1, solutions) 95 | ) 96 | 97 | # 4) clean-up solutions 98 | return self._clean_up(solutions) 99 | 100 | def _clean_up(self, solutions): 101 | """Cleaning up the solutions in three stages. 102 | 103 | 1) remove incomplete solutions 104 | 2) remove duplicate solutions 105 | 3) order the solutions by length (short to long) 106 | 107 | Args: 108 | solutions (list): collection of maze solutions 109 | Returns: 110 | list: cleaner collection of solutions 111 | """ 112 | # 1) remove incomplete solutions 113 | new_solutions = [] 114 | for sol in solutions: 115 | new_sol = None 116 | if self.end_edge: 117 | last = self._push_edge(self.end) 118 | # remove edge-case end cells 119 | if len(sol) > 2 and self._within_one(sol[-2], self.end): 120 | new_sol = sol[:-1] 121 | elif len(sol) > 2 and self._within_one(sol[-2], last): 122 | new_sol = sol[:-1] + [last] 123 | else: 124 | # remove inside-maze-case end cells 125 | if len(sol) > 2 and self._within_one(sol[-2], self.end): 126 | new_sol = sol[:-1] 127 | 128 | if new_sol: 129 | if new_sol[-1] == self.end: 130 | new_sol = new_sol[:-1] 131 | new_solutions.append(new_sol) 132 | 133 | # 2) remove duplicate solutions 134 | solutions = self._remove_duplicate_sols(new_solutions) 135 | 136 | # order the solutions by length (short to long) 137 | return sorted(solutions, key=len) 138 | 139 | def _remove_duplicate_sols(self, sols): 140 | """Remove duplicate solutions using subsetting. 141 | 142 | Args: 143 | solutions (list): collection of maze solutions 144 | Returns: 145 | list: collection of unique maze solutions 146 | """ 147 | return [list(s) for s in set(map(tuple, sols))] 148 | -------------------------------------------------------------------------------- /docs/MAZE_SOLVE_ALGOS.md: -------------------------------------------------------------------------------- 1 | # Maze-Solving Algorithms 2 | 3 | ##### Go back to the main [README](../README.md) 4 | 5 | Because users are allowed to create and modify mazes in such a great variety of way, the `mazelib` library will only support universal maze-solving algorithms. That is, `mazelib` will not implement any maze-solving algorithm that can't, for instance, solve imperfect mazes (those with loops or more than one solution). Otherwise, the user will have to know internal details about the maze generating / soliving algorithms they use, and if they are compatible. 6 | 7 | 8 | ## Chain Algorithm 9 | 10 | ###### The Algorithm 11 | 12 | 1. draw a straight-ish line from start to end, ignore the walls. 13 | 2. Follow the line from start to end. 14 | 1. If you bump into a wall, you have to go around. 15 | 2. Send out backtracking robots in the 1 or 2 open directions. 16 | 1. If the robot can find your new point, continue on. 17 | 2. If the robot intersects your line at a point that is further down stream, pick up the path there. 18 | 3. repeat step 2 until you are at the end. 19 | 1. If both robots return to their original location and direction, the maze is unsolvable. 20 | 21 | ###### Optional Parameters 22 | 23 | * *Turn*: String ['left', 'right'] 24 | * Do you want to follow the right wall or the left wall? (default 'right') 25 | 26 | ###### Results 27 | 28 | * 1 solution 29 | * not the shortest solution 30 | * works against imperfect mazes 31 | 32 | ###### Notes 33 | 34 | The idea here is that you break the maze up into a sequence of smaller mazes. There are undoubtedly cases where this helps and cases where this is a terrible idea. Caveat emptor. 35 | 36 | This algorithm uses the Wall Follower algorithm to solve the sub-mazes. As such, it is significantly more complicated and memory-intensive than your standard Wall Follower. 37 | 38 | 39 | ## Collision Solver 40 | 41 | ###### The Algorithm 42 | 43 | 1. step through the maze, flooding all directions equally 44 | 2. if two flood paths meet, create a wall where they meet 45 | 3. fill in all dead ends 46 | 4. repeat until there are no more collisions 47 | 48 | ###### Results 49 | 50 | * finds shortests solutions 51 | * doesn't always work against imperfect mazes 52 | 53 | ###### Notes 54 | 55 | On a perfect maze, this is little different than the Dead End Filler algorithm. But in heavily braided and imperfect mazes, this algorithm simply iterates over the whole maze a few more times and finds the optimal solutions. It is quite elegant. 56 | 57 | 58 | 59 | ## Random Mouse 60 | 61 | ###### The Algorithm: 62 | 63 | A mouse just wanders randomly around the maze until it finds the cheese. 64 | 65 | ###### Results 66 | 67 | * 1 solution 68 | * not the shortest solution 69 | * works against imperfect mazes 70 | 71 | ###### Notes 72 | 73 | Random mouse may never finish. Technically. It is certainly inefficient in time, but very efficient in memory. 74 | 75 | I highly recommend that this solver run in the default pruning mode, to get rid of all unnecessary branches, and backtracks, in the solution. 76 | 77 | 78 | ## Recursive Backtracker 79 | 80 | ###### The Algorithm: 81 | 82 | 1) Pick a random direction and follow it 83 | 2) Backtrack if and only if you hit a dead end. 84 | 85 | ###### Results 86 | 87 | * 1 solution 88 | * not the shortest solution 89 | * works against imperfect mazes 90 | 91 | ###### Notes 92 | 93 | Mathematically, there is very little difference between this algorithm and Random Mouse. The only difference is that at each point, Random Mouse might go right back where it came from. But Backtracker will only do that if it reaches a dead end. 94 | 95 | 96 | ## Shortest Path Finder 97 | 98 | ###### The Algorithm: 99 | 100 | 1) create a possible solution for each neighbor of the starting position 101 | 2) find the neighbors of the last element in each solution, branches create new solutions 102 | 3) repeat step 2 until you reach the end 103 | 4) The first solution to reach the end wins. 104 | 105 | ###### Results 106 | 107 | * finds all solutions 108 | * finds shortest solution(s) 109 | * works against imperfect mazes 110 | 111 | ###### Notes 112 | 113 | In CS terms, this is a Breadth-First Search algorithm that is cut short when the first solution is found. 114 | 115 | 116 | ## Shortest Paths Finder 117 | 118 | ###### The Algorithm 119 | 120 | 1) create a possible solution for each neighbor of the starting position 121 | 2) find the neighbors of the last element in each solution, branches create new solutions 122 | 3) repeat step 2 until you al solutions hit dead ends or reach the end 123 | 4) remove all dead end solutions 124 | 125 | ###### Results 126 | 127 | * finds all solutions 128 | * works against imperfect mazes 129 | 130 | ###### Notes 131 | 132 | In CS terms, this is a Breadth-First Search algorithm. It finds all unique, non-looped solutions to the maze. 133 | 134 | Though this version is optimized to improve speed, nothing could be done about the fact that this algorithm uses substantially more memory than just the maze grid itself. 135 | 136 | 137 | ## Trémaux's Algorithm 138 | 139 | ###### The Algorithm 140 | 141 | 1) Every time you visit a cell, mark it once. 142 | 2) When you hit a dead end, turn around and go back. 143 | 3) When you hit a junction you haven't visited, pick a new passage at random. 144 | 4) If you're walking down a new passage and hit a junction you have visited, treat it like a dead end and go back. 145 | 5) If walking down a passage you have visited before (i.e. marked once) and you hit a junction, take any new passage available, otherwise take an old passage (i.e. marked once). 146 | 6) When you finally reach the end, follow cells marked exactly once back to the start. 147 | 7) If the Maze has no solution, you'll find yourself at the start with all cells marked twice. 148 | 149 | ###### Results 150 | 151 | * Finds one non-optimal solution. 152 | * Works against imperfect mazes. 153 | 154 | ###### Notes 155 | 156 | This Maze-solving method is designed to be used by a human inside the Maze. 157 | 158 | 159 | ## Vocabulary 160 | 161 | 1. __cell__ - an open passage in the maze 162 | 2. __grid__ - the grid is the combination of all passages and barriers in the maze 163 | 3. __perfect__ - a maze is perfect if it has one and only one solution 164 | 4. __wall__ - an impassable barrier in the maze 165 | 166 | 167 | ##### Go back to the main [README](../README.md) 168 | -------------------------------------------------------------------------------- /mazelib/generate/Ellers.py: -------------------------------------------------------------------------------- 1 | from random import choice, random 2 | import numpy as np 3 | 4 | # If the code is not Cython-compiled, we need to add some imports. 5 | from cython import compiled 6 | 7 | if not compiled: 8 | from mazelib.generate.MazeGenAlgo import MazeGenAlgo 9 | 10 | 11 | class Ellers(MazeGenAlgo): 12 | """ 13 | The Ellers maze generating algorithm. 14 | 15 | 1. Put the cells of the first row each in their own set. 16 | 2. Join adjacent cells. But not if they are already in the same set. 17 | Merge the sets of these cells. 18 | 3. For each set in the row, create at least one vertical connection down to the next row. 19 | 4. Put any unconnected cells in the next row into their own set. 20 | 5. Repeast until the last row. 21 | 6. In the last row, join all adjacent cells that do not share a set. 22 | """ 23 | 24 | def __init__(self, w, h, xskew=0.5, yskew=0.5): 25 | super(Ellers, self).__init__(w, h) 26 | self.xskew = 0.0 if xskew < 0.0 else 1.0 if xskew > 1.0 else xskew 27 | self.yskew = 0.0 if yskew < 0.0 else 1.0 if yskew > 1.0 else yskew 28 | 29 | def generate(self): 30 | """Highest-level method that implements the maze-generating algorithm. 31 | 32 | Returns 33 | ------- 34 | np.array: returned matrix 35 | """ 36 | # create empty grid, with walls 37 | sets = np.empty((self.H, self.W), dtype=np.int8) 38 | sets.fill(-1) 39 | 40 | # initialize the first row cells to each exist in their own set 41 | max_set_number = 0 42 | 43 | # process all but the last row 44 | for r in range(1, self.H - 1, 2): 45 | max_set_number = self._init_row(sets, r, max_set_number) 46 | self._merge_one_row(sets, r) 47 | self._merge_down_a_row(sets, r) 48 | 49 | # process last row 50 | max_set_number = self._init_row(sets, self.H - 2, max_set_number) 51 | self._process_last_row(sets) 52 | 53 | # translate grid cell sets into a maze 54 | return self._create_grid_from_sets(sets) 55 | 56 | def _init_row(self, sets, row, max_set_number): 57 | """Initialize each cell in a row to its own set. 58 | 59 | Args: 60 | sets (np.array): grid representation of row sets 61 | row (int): row number 62 | max_set_number (int): counter used to determine how many rows/sets are left to work on 63 | Returns: 64 | int: latest counter for number of sets left to work on 65 | """ 66 | for c in range(1, self.W, 2): 67 | if sets[row][c] < 0: 68 | sets[row][c] = max_set_number 69 | max_set_number += 1 70 | 71 | return max_set_number 72 | 73 | def _merge_one_row(self, sets, r): 74 | """Randomly decide to merge cells within a column. 75 | 76 | Args: 77 | sets (np.array): grid representation of row sets 78 | r (int): row number 79 | Returns: None 80 | """ 81 | for c in range(1, self.W - 2, 2): 82 | if random() < self.xskew: 83 | if sets[r][c] != sets[r][c + 2]: 84 | sets[r][c + 1] = sets[r][c] 85 | self._merge_sets(sets, sets[r][c + 2], sets[r][c], max_row=r) 86 | 87 | def _merge_down_a_row(self, sets, start_row): 88 | """Create vertical connections in the maze. 89 | For the current row, cut down at least one passage for each cell set. 90 | 91 | Args: 92 | sets (np.array): grid representation of row sets 93 | start_row (int): index of row to start merging from 94 | Returns: None 95 | """ 96 | # this is not meant for the bottom row 97 | if start_row == self.H - 2: 98 | return 99 | 100 | # count how many cells of each set exist in a row 101 | set_counts = {} 102 | for c in range(1, self.W, 2): 103 | s = sets[start_row][c] 104 | if s not in set_counts: 105 | set_counts[s] = [c] 106 | else: 107 | set_counts[s] = set_counts[s] + [c] 108 | 109 | # merge down randomly, but at least once per set 110 | for s in set_counts: 111 | c = choice(set_counts[s]) 112 | sets[start_row + 1][c] = s 113 | sets[start_row + 2][c] = s 114 | 115 | for c in range(1, self.W - 2, 2): 116 | if random() < self.yskew: 117 | s = sets[start_row][c] 118 | if sets[start_row + 1][c] == -1: 119 | sets[start_row + 1][c] = s 120 | sets[start_row + 2][c] = s 121 | 122 | def _merge_sets(self, sets, from_set, to_set, max_row=-1): 123 | """Merge two different sets of grid cells into one. 124 | To improve performance, the grid will only be searched up to some maximum row number. 125 | 126 | Args: 127 | sets (np.array): grid representation of row sets 128 | from_set (int): set to merge FROM 129 | to_set (int): set to merge TO 130 | max_row (int): index of last row in the maze 131 | Returns: None 132 | """ 133 | if max_row < 0: 134 | max_row = self.H - 1 135 | 136 | for r in range(1, max_row + 1): 137 | for c in range(1, self.W - 1): 138 | if sets[r][c] == from_set: 139 | sets[r][c] = to_set 140 | 141 | def _process_last_row(self, sets): 142 | """Join all adjacent cells that do not share a set, and omit the vertical connections. 143 | 144 | Args: 145 | sets (np.array): grid representation of row sets 146 | Returns: None 147 | """ 148 | r = self.H - 2 149 | for c in range(1, self.W - 2, 2): 150 | if sets[r][c] != sets[r][c + 2]: 151 | sets[r][c + 1] = sets[r][c] 152 | self._merge_sets(sets, sets[r][c + 2], sets[r][c]) 153 | 154 | def _create_grid_from_sets(self, sets): 155 | """Translate the maze sets into a maze grid. 156 | 157 | Args: 158 | sets (np.array): grid representation of row sets 159 | Returns: 160 | np.array: final maze grid 161 | """ 162 | grid = np.empty((self.H, self.W), dtype=np.int8) 163 | grid.fill(0) 164 | 165 | for r in range(self.H): 166 | for c in range(self.W): 167 | if sets[r][c] == -1: 168 | grid[r][c] = 1 169 | 170 | return grid 171 | -------------------------------------------------------------------------------- /mazelib/generate/Wilsons.py: -------------------------------------------------------------------------------- 1 | from random import choice, randrange 2 | import numpy as np 3 | 4 | # If the code is not Cython-compiled, we need to add some imports. 5 | from cython import compiled 6 | 7 | if not compiled: 8 | from mazelib.generate.MazeGenAlgo import MazeGenAlgo 9 | 10 | RANDOM = 1 11 | SERPENTINE = 2 12 | 13 | 14 | class Wilsons(MazeGenAlgo): 15 | """The Wilsons maze-generating algorithm. 16 | 17 | 1. Choose a random cell and add it to the Uniform Spanning Tree (UST). 18 | 2. Select any cell that is not in the UST and perform a random walk until you find a cell that is. 19 | 3. Add the cells and walls visited in the random walk to the UST. 20 | 4. Repeat steps 2 and 3 until all cells have been added to the UST. 21 | """ 22 | 23 | def __init__(self, w, h, hunt_order="random"): 24 | super(Wilsons, self).__init__(w, h) 25 | 26 | # the user can define what order to hunt for the next cell in 27 | if hunt_order.lower().strip() == "serpentine": 28 | self._hunt_order = SERPENTINE 29 | else: 30 | self._hunt_order = RANDOM 31 | 32 | def generate(self): 33 | """Highest-level method that implements the maze-generating algorithm. 34 | 35 | Returns 36 | ------- 37 | np.array: returned matrix 38 | """ 39 | # create empty grid 40 | grid = np.empty((self.H, self.W), dtype=np.int8) 41 | grid.fill(1) 42 | 43 | # find an arbitrary starting position 44 | grid[randrange(1, self.H, 2)][randrange(1, self.W, 2)] = 0 45 | num_visited = 1 46 | row, col = self._hunt(grid, num_visited) 47 | 48 | # perform many random walks, to fill the maze 49 | while row != -1 and col != -1: 50 | walk = self._generate_random_walk(grid, (row, col)) 51 | num_visited += self._solve_random_walk(grid, walk, (row, col)) 52 | (row, col) = self._hunt(grid, num_visited) 53 | 54 | return grid 55 | 56 | def _hunt(self, grid, count): 57 | """Based on how this algorithm was configured, choose hunt for the next starting point. 58 | 59 | Args: 60 | grid (np.array): maze array 61 | count (int): max number of times to iterate 62 | Returns: 63 | tuple: next cell 64 | """ 65 | if self._hunt_order == SERPENTINE: 66 | return self._hunt_serpentine(grid, count) 67 | else: 68 | return self._hunt_random(grid, count) 69 | 70 | def _hunt_random(self, grid, count): 71 | """Select the next cell to walk from, randomly. 72 | 73 | Args: 74 | grid (np.array): maze array 75 | count (int): max number of times to iterate 76 | Returns: 77 | tuple: next cell 78 | """ 79 | if count >= (self.h * self.w): 80 | return (-1, -1) 81 | 82 | return (randrange(1, self.H, 2), randrange(1, self.W, 2)) 83 | 84 | def _hunt_serpentine(self, grid, count): 85 | """Select the next cell to walk from by cycling through every grid cell in order. 86 | 87 | Args: 88 | grid (np.array): maze array 89 | count (int): max number of times to iterate 90 | Returns: 91 | tuple: next cell 92 | """ 93 | cell = (1, -1) 94 | found = False 95 | 96 | while not found: 97 | cell = (cell[0], cell[1] + 2) 98 | if cell[1] > (self.W - 2): 99 | cell = (cell[0] + 2, 1) 100 | if cell[0] > (self.H - 2): 101 | return (-1, -1) 102 | 103 | if grid[cell[0]][cell[1]] != 0: 104 | found = True 105 | 106 | return cell 107 | 108 | def _generate_random_walk(self, grid, start): 109 | """From a given starting position, walk randomly until you hit a visited cell. 110 | 111 | The returned walk object is a dictionary mapping your location (cell) to a 112 | direction. If you randomly walk over the same cell twice, you overwrite 113 | the direction at that location. 114 | 115 | Args: 116 | grid (np.array): maze array 117 | start (tuple): position to start from 118 | Returns: 119 | dict: map of your location to the direction you want to travel 120 | """ 121 | direction = self._random_dir(start) 122 | walk = {} 123 | walk[start] = direction 124 | current = self._move(start, direction) 125 | 126 | while grid[current[0]][current[1]] == 1: 127 | direction = self._random_dir(current) 128 | walk[current] = direction 129 | current = self._move(current, direction) 130 | 131 | return walk 132 | 133 | def _random_dir(self, current): 134 | """Take a step on one random (but valid) direction. 135 | 136 | Args: 137 | current (tuple): cell to start from 138 | Returns: 139 | tuple: random, valid direction to travel to 140 | """ 141 | r, c = current 142 | options = [] 143 | if r > 1: 144 | options.append(0) # North 145 | if r < (self.H - 2): 146 | options.append(1) # South 147 | if c > 1: 148 | options.append(2) # East 149 | if c < (self.W - 2): 150 | options.append(3) # West 151 | 152 | direction = choice(options) 153 | if direction == 0: 154 | return (-2, 0) # North 155 | elif direction == 1: 156 | return (2, 0) # South 157 | elif direction == 2: 158 | return (0, -2) # East 159 | else: 160 | return (0, 2) # West 161 | 162 | def _move(self, start, direction): 163 | """Convolve a position tuple with a direction tuple to generate a new position. 164 | 165 | Args: 166 | start (tuple): position to start from 167 | direction (tuple): vector direction to travel to 168 | Returns: 169 | tuple: position of next cell to travel to 170 | """ 171 | return (start[0] + direction[0], start[1] + direction[1]) 172 | 173 | def _solve_random_walk(self, grid, walk, start): 174 | """Move through the random walk, visiting all the cells you touch, 175 | and breaking down the walls you cross. 176 | 177 | Args: 178 | grid (np.array): maze array 179 | walk (dict): random walk directions, from each cell 180 | start (tuple): position of cell to star the process at 181 | Returns: 182 | int: number of steps taken to complete the process 183 | """ 184 | visits = 0 185 | current = start 186 | 187 | while grid[current[0]][current[1]] != 0: 188 | grid[current] = 0 189 | next1 = self._move(current, walk[current]) 190 | grid[(next1[0] + current[0]) // 2, (next1[1] + current[1]) // 2] = 0 191 | visits += 1 192 | current = next1 193 | 194 | return visits 195 | -------------------------------------------------------------------------------- /test/test_maze.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import unittest 3 | from mazelib.generate.Prims import Prims 4 | from mazelib.solve.Collision import Collision 5 | from mazelib.mazelib import Maze 6 | 7 | 8 | class MazeTest(unittest.TestCase): 9 | def _on_edge(self, grid, cell): 10 | """Helper method to determine if a point is on the edge of a maze. 11 | 12 | Args: 13 | grid (np.array): maze array 14 | cell (tuple): position of cell of interest 15 | Returns: 16 | boolean: Is this cell on the edge of the maze? 17 | """ 18 | r, c = cell 19 | 20 | if r == 0 or r == (grid.shape[0] - 1): 21 | return True 22 | elif c == 0 or c == (grid.shape[1] - 1): 23 | return True 24 | 25 | return False 26 | 27 | def _num_turns(self, path): 28 | """Helper method to count the number of turns in a path. 29 | 30 | Args: 31 | path (list): sequence of cells to path through maze 32 | Returns: 33 | int: number of turns in the path 34 | """ 35 | if len(path) < 3: 36 | return 0 37 | 38 | num = 0 39 | for i in range(1, len(path) - 1): 40 | same_col = path[i - 1][0] == path[i][0] == path[i + 1][0] 41 | same_row = path[i - 1][1] == path[i][1] == path[i + 1][1] 42 | if not same_row and not same_col: 43 | num += 1 44 | 45 | return num 46 | 47 | def test_grid_size(self): 48 | """Test that the array representation for the maze is the exact size we want it to be.""" 49 | h = 4 50 | w = 5 51 | H = 2 * h + 1 52 | W = 2 * w + 1 53 | 54 | m = Maze(1337) 55 | m.generator = Prims(h, w) 56 | m.generate() 57 | 58 | assert m.grid.shape[0] == H 59 | assert m.grid.shape[1] == W 60 | 61 | def test_inner_entrances(self): 62 | """Test that the entrances can be correctly generated not on the edges of the map.""" 63 | h = 4 64 | w = 5 65 | 66 | for i in range(10): 67 | m = Maze(999 + i) 68 | m.generator = Prims(h, w) 69 | m.generate() 70 | m.generate_entrances(False, False) 71 | 72 | assert not self._on_edge(m.grid, m.start) 73 | assert not self._on_edge(m.grid, m.end) 74 | 75 | def test_outer_entrances(self): 76 | """Test that the entrances can be correctly generated on the edges of the map.""" 77 | h = 3 78 | w = 3 79 | 80 | for i in range(10): 81 | m = Maze(1200 + 1) 82 | m.generator = Prims(h, w) 83 | m.generate() 84 | m.generate_entrances() 85 | 86 | assert self._on_edge(m.grid, m.start) 87 | assert self._on_edge(m.grid, m.end) 88 | 89 | def test_generator_wipe(self): 90 | """Test that the running the primary generate() method twice correctly wipes the entrances and solutions.""" 91 | h = 4 92 | w = 5 93 | 94 | for i in range(10): 95 | m = Maze(1033 + 1) 96 | m.generator = Prims(h, w) 97 | m.generate() 98 | m.generate_entrances() 99 | m.generate() 100 | 101 | assert m.start is None 102 | assert m.end is None 103 | assert m.solutions is None 104 | 105 | def test_monte_carlo(self): 106 | """Test that the basic Monte Carlo maze generator.""" 107 | h = 4 108 | w = 5 109 | H = 2 * h + 1 110 | W = 2 * w + 1 111 | 112 | m = Maze(104) 113 | m.generator = Prims(h, w) 114 | m.solver = Collision() 115 | m.generate_monte_carlo(3) 116 | 117 | # grid size 118 | assert m.grid.shape[0] == H 119 | assert m.grid.shape[1] == W 120 | 121 | # test entrances are outer 122 | assert self._on_edge(m.grid, m.start) 123 | assert self._on_edge(m.grid, m.end) 124 | 125 | def test_monte_carlo_reducer(self): 126 | """Test that the reducer functionality on the Monte Carlo maze generator.""" 127 | h = 4 128 | w = 5 129 | H = 2 * h + 1 130 | W = 2 * w + 1 131 | 132 | m = Maze(105) 133 | m.generator = Prims(h, w) 134 | m.solver = Collision() 135 | m.generate_monte_carlo(3, reducer=self._num_turns) 136 | 137 | # grid size 138 | assert m.grid.shape[0] == H 139 | assert m.grid.shape[1] == W 140 | 141 | # test entrances are outer 142 | assert self._on_edge(m.grid, m.start) 143 | assert self._on_edge(m.grid, m.end) 144 | 145 | def test_maze_to_string(self): 146 | """Test that the 'to string' functionality is sane.""" 147 | m = Maze(106) 148 | m.generator = Prims(3, 3) 149 | 150 | # fake some maze results, to test against 151 | m.grid = np.array( 152 | [ 153 | [1, 1, 1, 1, 1, 1, 1], 154 | [1, 0, 0, 0, 0, 0, 1], 155 | [1, 0, 1, 0, 1, 1, 1], 156 | [1, 0, 1, 0, 0, 0, 1], 157 | [1, 1, 1, 0, 1, 1, 1], 158 | [1, 0, 0, 0, 0, 0, 1], 159 | [1, 1, 1, 1, 1, 1, 1], 160 | ] 161 | ) 162 | m.start = (5, 0) 163 | m.end = (3, 6) 164 | m.solutions = [[(5, 1), (5, 2), (5, 3), (5, 4), (5, 5), (4, 5), (3, 5)]] 165 | 166 | s = str(m).split("\n") 167 | 168 | assert s[0].strip() == "#######" 169 | assert s[2].strip() == "# # ###" 170 | assert s[3].strip() == "# # +E" 171 | assert s[5].strip() == "S+++++#" 172 | assert s[6].strip() == "#######" 173 | 174 | def test_invalid_inputs(self): 175 | """Test that the correct errors are thrown when the top-level methods are called incorrectly.""" 176 | m = Maze(107) 177 | 178 | # should not be able to generate or solve if neither algorithm was set 179 | self.assertRaises(AssertionError, m.generate) 180 | self.assertRaises(AssertionError, m.solve) 181 | 182 | # even if the generator algorithm is set, you have to run it 183 | m.generator = Prims(3, 3) 184 | self.assertRaises(AssertionError, m.solve) 185 | 186 | # the pretty-print, string formats should fail gracefully 187 | m.start = (1, 1) 188 | m.end = (3, 3) 189 | assert str(m) == "" 190 | assert repr(m) == "" 191 | 192 | # the Monte Carlo method has a special zero-to-one input scalar 193 | self.assertRaises(AssertionError, m.generate_monte_carlo, True, 3, -1.0) 194 | self.assertRaises(AssertionError, m.generate_monte_carlo, True, 3, 10.0) 195 | 196 | def test_set_seed(self): 197 | """Test the Maze.set_seed staticmethod, to make sure we can control the random seeding.""" 198 | m = Maze(9090) 199 | m.generator = Prims(7, 7) 200 | m.generate() 201 | grid0 = str(m) 202 | 203 | m = Maze(9090) 204 | m.generator = Prims(7, 7) 205 | m.generate() 206 | grid1 = str(m) 207 | 208 | assert grid0 == grid1 209 | 210 | m.generator = Prims(7, 7) 211 | m.generate() 212 | grid2 = str(m) 213 | 214 | assert grid0 != grid2 215 | 216 | 217 | if __name__ == "__main__": 218 | unittest.main(argv=["first-arg-is-ignored"], exit=False) 219 | -------------------------------------------------------------------------------- /mazelib/solve/MazeSolveAlgo.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from numpy.random import shuffle 3 | 4 | 5 | class MazeSolveAlgo: 6 | __metaclass__ = abc.ABCMeta 7 | 8 | def solve(self, grid, start, end): 9 | """Helper method to solve a init the solver before solving the maze. 10 | 11 | Args: 12 | grid (np.array): maze array 13 | start (tuple): position in maze to start from 14 | end (tuple): position in maze to finish at 15 | Returns: 16 | list: final solutions 17 | """ 18 | self._solve_preprocessor(grid, start, end) 19 | return self._solve() 20 | 21 | def _solve_preprocessor(self, grid, start, end): 22 | """Ensure the maze mazes any sense before you solve it. 23 | 24 | Args: 25 | grid (np.array): maze array 26 | start (tuple): position in maze to start from 27 | end (tuple): position in maze to finish at 28 | Returns: None 29 | """ 30 | self.grid = grid.copy() 31 | self.start = start 32 | self.end = end 33 | 34 | # validating checks 35 | assert grid is not None, "Maze grid is not set." 36 | assert start is not None and end is not None, "Entrances are not set." 37 | assert ( 38 | start[0] >= 0 and start[0] < grid.shape[0] 39 | ), "Entrance is outside the grid." 40 | assert ( 41 | start[1] >= 0 and start[1] < grid.shape[1] 42 | ), "Entrance is outside the grid." 43 | assert end[0] >= 0 and end[0] < grid.shape[0], "Entrance is outside the grid." 44 | assert end[1] >= 0 and end[1] < grid.shape[1], "Entrance is outside the grid." 45 | 46 | @abc.abstractmethod 47 | def _solve(self): 48 | return None 49 | 50 | """ 51 | All of the methods below this are helper methods, 52 | common to many maze-solving algorithms. 53 | """ 54 | 55 | def _find_unblocked_neighbors(self, posi): 56 | """Find all the grid neighbors of the current position; visited, or not. 57 | 58 | Args: 59 | posi (tuple): cell of interest 60 | Returns: 61 | list: all the unblocked neighbors to this cell 62 | """ 63 | r, c = posi 64 | ns = [] 65 | 66 | if r > 1 and not self.grid[r - 1, c] and not self.grid[r - 2, c]: 67 | ns.append((r - 2, c)) 68 | if ( 69 | r < self.grid.shape[0] - 2 70 | and not self.grid[r + 1, c] 71 | and not self.grid[r + 2, c] 72 | ): 73 | ns.append((r + 2, c)) 74 | if c > 1 and not self.grid[r, c - 1] and not self.grid[r, c - 2]: 75 | ns.append((r, c - 2)) 76 | if ( 77 | c < self.grid.shape[1] - 2 78 | and not self.grid[r, c + 1] 79 | and not self.grid[r, c + 2] 80 | ): 81 | ns.append((r, c + 2)) 82 | 83 | shuffle(ns) 84 | return ns 85 | 86 | def _midpoint(self, a, b): 87 | """Find the wall cell between to passage cells. 88 | 89 | Args: 90 | a (tuple): first cell 91 | b (tuple): second cell 92 | Returns: 93 | tuple: cell half way between the first two 94 | """ 95 | return (a[0] + b[0]) // 2, (a[1] + b[1]) // 2 96 | 97 | def _move(self, start, direction): 98 | """Convolve a position tuple with a direction tuple to generate a new position. 99 | 100 | Args: 101 | start (tuple): position cell to start at 102 | direction (tuple): vector cell of direction to travel to 103 | Returns: 104 | tuple: end result of movement 105 | """ 106 | return tuple(map(sum, zip(start, direction))) 107 | 108 | def _on_edge(self, cell): 109 | """Return True if the cell lia on the edge of the maze grid. 110 | 111 | Args: 112 | cell (tuple): some place in the grid 113 | Returns: 114 | bool: Is the cell on the edge of the maze? 115 | """ 116 | # ruff: noqa: SIM103 117 | r, c = cell 118 | 119 | if r == 0 or r == self.grid.shape[0] - 1: 120 | return True 121 | if c == 0 or c == self.grid.shape[1] - 1: 122 | return True 123 | 124 | return False 125 | 126 | def _push_edge(self, cell): 127 | """You may need to find the cell directly inside of a start or end cell. 128 | 129 | Args: 130 | cell (tuple): some place in the grid 131 | Returns: 132 | tuple: the new cell location, pushed from the edge 133 | """ 134 | r, c = cell 135 | 136 | if r == 0: 137 | return (1, c) 138 | elif r == (self.grid.shape[0] - 1): 139 | return (r - 1, c) 140 | elif c == 0: 141 | return (r, 1) 142 | else: 143 | return (r, c - 1) 144 | 145 | def _within_one(self, cell, desire): 146 | """Return True if the current cell is within one move of the desired cell. 147 | 148 | Note, this might be one full more, or one half move. 149 | 150 | Args: 151 | cell (tuple): position to start at 152 | desire (tuple): position you want to be at 153 | Returns: 154 | bool: Are you within one movement of your goal? 155 | """ 156 | if not cell or not desire: 157 | return False 158 | 159 | if cell[0] == desire[0]: 160 | if abs(cell[1] - desire[1]) < 2: 161 | return True 162 | elif cell[1] == desire[1]: 163 | if abs(cell[0] - desire[0]) < 2: 164 | return True 165 | 166 | return False 167 | 168 | def _prune_solution(self, solution): 169 | """In the process of solving a maze, the algorithm might go down 170 | the wrong corridor then backtrack. These extraneous steps need to be removed. 171 | Also, clean up the end points. 172 | 173 | Args: 174 | solution (list): raw maze solution 175 | Returns: 176 | list: cleaner, tightened up solution to the maze 177 | """ 178 | found = True 179 | attempt = 0 180 | max_attempt = len(solution) 181 | 182 | while found and len(solution) > 2 and attempt < max_attempt: 183 | found = False 184 | attempt += 1 185 | 186 | for i in range(len(solution) - 1): 187 | first = solution[i] 188 | if first in solution[i + 1 :]: 189 | first_i = i 190 | last_i = solution[i + 1 :].index(first) + i + 1 191 | found = True 192 | break 193 | 194 | if found: 195 | solution = solution[:first_i] + solution[last_i:] 196 | 197 | # solution does not include entrances 198 | if len(solution) > 1: 199 | if solution[0] == self.start: 200 | solution = solution[1:] 201 | if solution[-1] == self.end: 202 | solution = solution[:-1] 203 | 204 | return solution 205 | 206 | def prune_solutions(self, solutions): 207 | """Prune all the duplicate cells from all solutions, and fix end points. 208 | 209 | Args: 210 | solutions (list): multiple raw solutions 211 | Returns: 212 | list: the above solutions, cleaned up 213 | """ 214 | return [self._prune_solution(s) for s in solutions] 215 | -------------------------------------------------------------------------------- /mazelib/solve/Chain.py: -------------------------------------------------------------------------------- 1 | from random import choice 2 | 3 | # If the code is not Cython-compiled, we need to add some imports. 4 | from cython import compiled 5 | 6 | if not compiled: 7 | from mazelib.solve.MazeSolveAlgo import MazeSolveAlgo 8 | 9 | 10 | class Chain(MazeSolveAlgo): 11 | """ 12 | The Chain maze solving algorithm. 13 | 14 | 1. draw a straight-ish line from start to end, ignore the walls. 15 | 2. Follow the line from start to end. 16 | a. If you bump into a wall, you have to go around. 17 | b. Send out backtracking robots in the 1 or 2 open directions. 18 | c. If the robot can find your new point, continue on. 19 | d. If the robot intersects your line at a point that is further down stream, 20 | pick up the path there. 21 | 3. repeat step 2 until you are at the end. 22 | a. If both robots return to their original location and direction, 23 | the maze is unsolvable. 24 | """ 25 | 26 | def __init__(self, turn="right"): 27 | # turn can take on values 'left' or 'right' 28 | if turn == "left": 29 | self.directions = [(-2, 0), (0, -2), (2, 0), (0, 2)] 30 | else: # default to right turns 31 | self.directions = [(-2, 0), (0, 2), (2, 0), (0, -2)] 32 | 33 | def _solve(self): 34 | """Solve a maze by trying to head directly, diagonally across the maze, 35 | when you hit a barrier, send out a back-tracking robot until you get to the next 36 | cell along the diagonal. 37 | 38 | Returns 39 | ------- 40 | list: maze solution path 41 | """ 42 | guiding_line = self._draw_guiding_line() 43 | len_guiding_line = len(guiding_line) 44 | 45 | current = 0 46 | solution = [guiding_line[0]] 47 | 48 | while current < len_guiding_line - 1: 49 | # try to move to the next location 50 | success = self._try_direct_move(solution, guiding_line, current) 51 | if success: 52 | current += 1 53 | else: 54 | current = self._send_out_robots(solution, guiding_line, current) 55 | 56 | return [solution] 57 | 58 | def _send_out_robots(self, solution, guiding_line, i): 59 | """Send out backtracking robots in all directions, to look for the next point in the guiding line. 60 | 61 | Args: 62 | solutions (list): The current solution path 63 | guiding_line (list): diagonal to the maze finish 64 | i (int): index of next step in maze 65 | Returns: 66 | int: position along the solution diagonal 67 | """ 68 | ns = self._find_unblocked_neighbors(guiding_line[i]) 69 | 70 | # create a robot for each open neighbor 71 | robot_paths = [] 72 | for n in ns: 73 | robot_path = [] 74 | robot_path.append(guiding_line[i]) 75 | robot_path.append(self._midpoint(guiding_line[i], n)) 76 | robot_path.append(n) 77 | robot_paths.append(robot_path) 78 | 79 | # randomly walk each robot, until it finds the guiding line or quits 80 | for j, path in enumerate(robot_paths): 81 | robot_paths[j] = self._backtracking_solve(path, guiding_line[i + 1]) 82 | 83 | # add the shortest path to the solution 84 | shortest_robot_path = min(robot_paths, key=len) 85 | min_len = len(shortest_robot_path) 86 | for j, path in enumerate(robot_paths[1:]): 87 | if len(path) < min_len: 88 | shortest_robot_path = path 89 | min_len = len(path) 90 | solution += shortest_robot_path[1:] 91 | 92 | return guiding_line.index(solution[-1]) 93 | 94 | def _backtracking_solve(self, solution, goal): 95 | """Our robots will attempt to solve the sub-maze using backtracking solver. 96 | 97 | Args: 98 | solution (list): current path to the finish 99 | goal (tuple): next cell along the diagonal path to the finish 100 | Returns: 101 | list: new solution 102 | """ 103 | path = list(solution) 104 | 105 | # pick a random neighbor and travel to it, until you're at the end 106 | while not self._within_one(path[-1], goal): 107 | ns = self._find_unblocked_neighbors(path[-1]) 108 | 109 | # do no go where you've just been 110 | if len(ns) > 1 and len(path) > 2: 111 | if path[-3] in ns: 112 | ns.remove(path[-3]) 113 | 114 | nxt = choice(ns) 115 | path.append(self._midpoint(path[-1], nxt)) 116 | path.append(nxt) 117 | 118 | return path 119 | 120 | def _try_direct_move(self, solution, guiding_line, i): 121 | """The path to the next spot on the guiding line might be open. 122 | If so, add a couple steps to the solution. If not, return False. 123 | 124 | Args: 125 | solutions (list): The current solution path 126 | guiding_line (list): diagonal to the maze finish 127 | i (int): index of next step in maze 128 | Returns: 129 | bool: Did we find a better partial solution to the maze? 130 | """ 131 | r, c = guiding_line[i] 132 | next = guiding_line[i + 1] 133 | 134 | rdiff = next[0] - r 135 | cdiff = next[1] - c 136 | 137 | # calculate the cell next door and see if it's open 138 | if rdiff == 0 or cdiff == 0: 139 | if self.grid[r + rdiff // 2, c + cdiff // 2] == 0: 140 | solution.append((r + rdiff // 2, c + cdiff // 2)) 141 | solution.append((r + rdiff, c + cdiff)) 142 | return True 143 | else: 144 | if ( 145 | self.grid[r + rdiff // 2, c] == 0 146 | and self.grid[r + rdiff, c + cdiff // 2] == 0 147 | ): 148 | solution.append((r + rdiff // 2, c)) 149 | solution.append((r + rdiff, c)) 150 | solution.append((r + rdiff, c + cdiff // 2)) 151 | solution.append((r + rdiff, c + cdiff)) 152 | return True 153 | elif ( 154 | self.grid[r, c + cdiff // 2] == 0 155 | and self.grid[r + rdiff // 2, c + cdiff] == 0 156 | ): 157 | solution.append((r, c + cdiff // 2)) 158 | solution.append((r, c + cdiff)) 159 | solution.append((r + rdiff // 2, c + cdiff)) 160 | solution.append((r + rdiff, c + cdiff)) 161 | return True 162 | else: 163 | return False 164 | 165 | return False 166 | 167 | def _draw_guiding_line(self): 168 | """Draw a (mostly) straight line from start to end. 169 | 170 | Returns 171 | ------- 172 | list: the (probably digonal) straight line across the maze to the end 173 | """ 174 | r2, c2 = self.end 175 | path = [] 176 | 177 | current = self.start 178 | if self._on_edge(self.start): 179 | current = self._push_edge(self.start) 180 | path.append(current) 181 | 182 | while not self._within_one(current, self.end): 183 | r1, c1 = current 184 | rdiff = cdiff = 0 185 | 186 | if abs(r1 - r2) > 1: 187 | rdiff = 2 if r1 < r2 else -2 188 | if abs(c1 - c2) > 1: 189 | cdiff = 2 if c1 < c2 else -2 190 | 191 | current = (r1 + rdiff, c1 + cdiff) 192 | path.append(current) 193 | 194 | return path 195 | -------------------------------------------------------------------------------- /mazelib/mazelib.py: -------------------------------------------------------------------------------- 1 | from random import randrange 2 | 3 | 4 | class Maze: 5 | """This is a primary object meant to hold a rectangular, 2D maze. 6 | This object includes the methods used to generate and solve the maze, 7 | as well as the start and end points. 8 | """ 9 | 10 | def __init__(self, seed=None): 11 | self.generator = None 12 | self.grid = None 13 | self.start = None 14 | self.end = None 15 | self.transmuters = [] 16 | self.solver = None 17 | self.solutions = None 18 | self.prune = True 19 | Maze.set_seed(seed) 20 | 21 | @staticmethod 22 | def set_seed(seed): 23 | """Helper method to set the random seeds for all the random seed for all the random libraries we are using. 24 | 25 | Args: 26 | seed (int): random seed number 27 | Returns: None 28 | """ 29 | if seed is not None: 30 | import random 31 | 32 | random.seed(seed) 33 | import numpy as np 34 | 35 | np.random.seed(seed) 36 | 37 | def generate(self): 38 | """Public method to generate a new maze, and handle some clean-up.""" 39 | assert self.generator is not None, "No maze-generation algorithm has been set." 40 | 41 | self.grid = self.generator.generate() 42 | self.start = None 43 | self.end = None 44 | self.solutions = None 45 | 46 | def generate_entrances(self, start_outer=True, end_outer=True): 47 | """Generate maze entrances. Entrances can be on the walls, or inside the maze. 48 | 49 | Args: 50 | start_outer (bool): Do you want the start of the maze to be on an outer wall? 51 | end_outer (bool): Do you want the end of the maze to be on an outer wall? 52 | Returns: None 53 | """ 54 | if start_outer and end_outer: 55 | self._generate_outer_entrances() 56 | elif not start_outer and not end_outer: 57 | self._generate_inner_entrances() 58 | elif start_outer: 59 | self.start, self.end = self._generate_opposite_entrances() 60 | else: 61 | self.end, self.start = self._generate_opposite_entrances() 62 | 63 | # the start and end shouldn't be right next to each other 64 | if abs(self.start[0] - self.end[0]) + abs(self.start[1] - self.end[1]) < 2: 65 | self.generate_entrances(start_outer, end_outer) 66 | 67 | def _generate_outer_entrances(self): 68 | """Generate maze entrances, along the outer walls.""" 69 | H = self.grid.shape[0] 70 | W = self.grid.shape[1] 71 | 72 | start_side = randrange(4) 73 | 74 | # maze entrances will be on opposite sides of the maze. 75 | if start_side == 0: 76 | self.start = (0, randrange(1, W, 2)) # North 77 | self.end = (H - 1, randrange(1, W, 2)) 78 | elif start_side == 1: 79 | self.start = (H - 1, randrange(1, W, 2)) # South 80 | self.end = (0, randrange(1, W, 2)) 81 | elif start_side == 2: 82 | self.start = (randrange(1, H, 2), 0) # West 83 | self.end = (randrange(1, H, 2), W - 1) 84 | else: 85 | self.start = (randrange(1, H, 2), W - 1) # East 86 | self.end = (randrange(1, H, 2), 0) 87 | 88 | def _generate_inner_entrances(self): 89 | """Generate maze entrances, randomly within the maze.""" 90 | H, W = self.grid.shape 91 | 92 | self.start = (randrange(1, H, 2), randrange(1, W, 2)) 93 | end = (randrange(1, H, 2), randrange(1, W, 2)) 94 | 95 | # make certain the start and end points aren't the same 96 | while end == self.start: 97 | end = (randrange(1, H, 2), randrange(1, W, 2)) 98 | 99 | self.end = end 100 | 101 | def _generate_opposite_entrances(self): 102 | """Generate one inner and one outer entrance. 103 | 104 | Returns 105 | ------- 106 | tuple: start cell, end cell 107 | """ 108 | H, W = self.grid.shape 109 | 110 | start_side = randrange(4) 111 | 112 | # pick a side for the outer maze entrance 113 | if start_side == 0: 114 | first = (0, randrange(1, W, 2)) # North 115 | elif start_side == 1: 116 | first = (H - 1, randrange(1, W, 2)) # South 117 | elif start_side == 2: 118 | first = (randrange(1, H, 2), 0) # West 119 | else: 120 | first = (randrange(1, H, 2), W - 1) # East 121 | 122 | # create an inner maze entrance 123 | second = (randrange(1, H, 2), randrange(1, W, 2)) 124 | 125 | return (first, second) 126 | 127 | def generate_monte_carlo(self, repeat, entrances=3, difficulty=1.0, reducer=len): 128 | """Use the Monte Carlo method to generate a maze of defined difficulty. 129 | 130 | This method assumes the generator and solver algorithms are already set. 131 | 132 | 1. Generate a maze. 133 | 2. For each maze, generate a series of entrances. 134 | 3. To eliminate boring entrance choices, select only the entrances 135 | that yield the longest solution to a given maze. 136 | 4. Repeat steps 1 through 3 for several mazes. 137 | 5. Order the mazes based on a reduction function applied to their maximal 138 | solutions. By default, this reducer will return the solution length. 139 | 6. Based on the 'difficulty' parameter, select one of the mazes. 140 | 141 | Args: 142 | repeat (int): How many mazes do you want to generate? 143 | entrances (int): How many different entrance combinations do you want to try? 144 | difficulty (float): How difficult do you want the final maze to be (zero to one). 145 | reducer (function): How do you want to determine solution difficulty (default is length). 146 | Returns: None 147 | """ 148 | assert ( 149 | difficulty >= 0.0 and difficulty <= 1.0 150 | ), "Maze difficulty must be between 0 to 1." 151 | 152 | # generate different mazes 153 | mazes = [] 154 | for _ in range(repeat): 155 | self.generate() 156 | this_maze = [] 157 | 158 | # for each maze, generate different entrances, and solve 159 | for _ in range(entrances): 160 | self.generate_entrances() 161 | self.solve() 162 | this_maze.append( 163 | { 164 | "grid": self.grid, 165 | "start": self.start, 166 | "end": self.end, 167 | "solutions": self.solutions, 168 | } 169 | ) 170 | 171 | # for each maze, find the longest solution 172 | mazes.append(max(this_maze, key=lambda k: len(k["solutions"]))) 173 | 174 | # sort the mazes by the length of their solution 175 | mazes = sorted(mazes, key=lambda k: reducer(k["solutions"][0])) 176 | 177 | # based on optional parameter, choose the maze of the correct difficulty 178 | posi = int((len(mazes) - 1) * difficulty) 179 | 180 | # save final results of Monte Carlo Simulations to this object 181 | self.grid = mazes[posi]["grid"] 182 | self.start = mazes[posi]["start"] 183 | self.end = mazes[posi]["end"] 184 | self.solutions = mazes[posi]["solutions"] 185 | 186 | def transmute(self): 187 | """Transmute an existing maze grid.""" 188 | assert self.grid is not None, "No maze grid yet exists to transmute." 189 | 190 | for transmuter in self.transmuters: 191 | transmuter.transmute(self.grid, self.start, self.end) 192 | 193 | def solve(self): 194 | """Public method to solve a new maze, if possible. 195 | 196 | Returns: None 197 | """ 198 | assert self.solver is not None, "No maze-solving algorithm has been set." 199 | assert ( 200 | self.start is not None and self.end is not None 201 | ), "Start and end times must be set first." 202 | 203 | self.solutions = self.solver.solve(self.grid, self.start, self.end) 204 | if self.prune: 205 | self.solutions = self.solver.prune_solutions(self.solutions) 206 | 207 | def tostring(self, entrances=False, solutions=False): 208 | """Return a string representation of the maze. 209 | This can also display the maze entrances/solutions IF they already exist. 210 | 211 | Args: 212 | entrances (bool): Do you want to show the entrances of the maze? 213 | solutions (bool): Do you want to show the solution to the maze? 214 | Returns: 215 | str: string representation of the maze 216 | """ 217 | if self.grid is None: 218 | return "" 219 | 220 | # build the walls of the grid 221 | txt = [] 222 | for row in self.grid: 223 | txt.append("".join(["#" if cell else " " for cell in row])) 224 | 225 | # insert the start and end points 226 | if entrances and self.start and self.end: 227 | r, c = self.start 228 | txt[r] = txt[r][:c] + "S" + txt[r][c + 1 :] 229 | r, c = self.end 230 | txt[r] = txt[r][:c] + "E" + txt[r][c + 1 :] 231 | 232 | # if extant, insert the solution path 233 | if solutions and self.solutions: 234 | for r, c in self.solutions[0]: 235 | txt[r] = txt[r][:c] + "+" + txt[r][c + 1 :] 236 | 237 | return "\n".join(txt) 238 | 239 | def __str__(self): 240 | """Display maze walls, entrances, and solutions, if available. 241 | 242 | Returns 243 | ------- 244 | str: string representation of the maze 245 | """ 246 | return self.tostring(True, True) 247 | 248 | def __repr__(self): 249 | """Display maze walls, entrances, and solutions, if available. 250 | 251 | Returns 252 | ------- 253 | str: string representation of the maze 254 | """ 255 | return self.__str__() 256 | -------------------------------------------------------------------------------- /docs/EXAMPLES.md: -------------------------------------------------------------------------------- 1 | # mazelib Examples 2 | 3 | ##### Go back to the main [README](../README.md) 4 | 5 | 6 | ## Generating Special Mazes 7 | 8 | #### Dungeons, sans Dragons 9 | 10 | When creating mazes for use in games you will frequently want to have big, empty rooms included within the maze. The DungeonRooms algorithm was included for just this purpose. It is a simple variation on the classic Hunt-and-Kill algorithm, but it accepts information about open rooms you want included in the maze. 11 | 12 | To open up rooms in a maze, DungeonRooms accepts two optional input parameters: 13 | 14 | * rooms: List(List(tuple, tuple)) 15 | * A list of lists, containing the top-left and bottom-right corners of the rooms you want to create. For best results, the corners of each room shouldhave odd-numbered coordinates. 16 | * grid: 2D NumPy array, of booleans, all set to `True` 17 | * A pre-built maze array filled with one, or many, rooms. 18 | 19 | Let's do an example of each method for defining input rooms: 20 | 21 | ##### Defining Room Corners 22 | 23 | Here we create a 24x33 maze with one rectangular 4x4 room, open between the corners (3, 3) and (7, 7): 24 | 25 | from mazelib.generate.DungeonRooms import DungeonRooms 26 | 27 | m = Maze() 28 | m.generator = DungeonRooms(24, 33, rooms=[[(3,3), (7,7)]]) 29 | m.generate() 30 | 31 | ##### Defining Rooms by an Input Grid 32 | 33 | Here we create a 4x4 maze with one rectangular 2x2 room, open between the corners (5, 5) and (7, 7): 34 | 35 | import numpy as np 36 | g = np.ones((9, 9), dtype=np.int8) 37 | g[5] = np.array([1,1,1,0,0,0,1]) 38 | a[6] = np.array([1,1,1,0,0,0,1]) 39 | g[7] = np.array([1,1,1,0,0,0,1]) 40 | 41 | m = Maze() 42 | m.generator = DungeonRooms(4, 4, grid=g) 43 | m.generate() 44 | 45 |  46 | 47 | 48 | #### Transmuting Attractive Mazes 49 | 50 | Perhaps you want more control over your maze. You have ideas in you imagine spiral mazes, or circular mazes with a room at the very center. The Perturbation algorithm will allow you to do all of these things. 51 | 52 | First, start with a simple spiral maze (which is trivial to solve): 53 | 54 | import numpy as np 55 | g = np.ones((11, 11), dtype=np.int8) 56 | g[1] = np.array([1,0,0,0,0,0,0,0,0,0,1]) 57 | g[2] = np.array([1,1,1,1,1,1,1,1,1,0,1]) 58 | g[3] = np.array([1,0,0,0,0,0,0,0,1,0,1]) 59 | g[4] = np.array([1,0,1,1,1,1,1,0,1,0,1]) 60 | g[5] = np.array([1,0,1,0,0,0,1,0,1,0,1]) 61 | g[6] = np.array([1,0,1,0,1,1,1,0,1,0,1]) 62 | g[7] = np.array([1,0,1,0,0,0,0,0,1,0,1]) 63 | g[8] = np.array([1,0,1,1,1,1,1,1,1,0,1]) 64 | g[9] = np.array([1,0,0,0,0,0,0,0,0,0,1]) 65 | 66 |  67 | 68 | from mazelib.generate.Prims import Prims 69 | from mazelib.transmute.Perturbation import Perturbation 70 | 71 | m = Maze() 72 | m.generator = Prims(5, 5) 73 | m.generate() 74 | m.grid = g # for a good example 75 | m.transmuters = [Perturbation(repeat=1, new_walls=3)] 76 | m.transmute() 77 | m.start = (1, 0) 78 | m.end = (5, 5) 79 | 80 | The end result is a maze that is *almost* a spiral, but enough different to still make a decent maze. 81 | 82 |  83 | 84 | ## Displaying a Maze 85 | 86 | For the rest of this section, let us assume we have already generated a maze: 87 | 88 | from mazelib import Maze 89 | from mazelib.generate.Prims import Prims 90 | 91 | m = Maze() 92 | m.generator = Prims(27, 34) 93 | m.generate() 94 | 95 | #### Example 1: Plotting the Maze in Plain Text 96 | 97 | If you want a low-key, fast way to view the maze you've generated, just use the library's built-in `tostring` method: 98 | 99 | print(m.tostring()) # print walls only 100 | print(m.tostring(True)) # print walls and entrances 101 | print(m.tostring(True, True)) # print walls, entrances, and solution 102 | print(str(m)) # print everything that is available 103 | print(m) # print everything that is available 104 | 105 | The above `print` calls would generate these types of plots, respectively: 106 | 107 | ########### ########### ########### 108 | # # # # S # # # S*# # # 109 | # ### # # # # ### # # # #*### # # # 110 | # # # # # # # # #*# # # 111 | # ### ##### # ### ##### #*### ##### 112 | # # # # #*** # 113 | ### ####### ### ####### ###*####### 114 | # E # E # *******E 115 | ########### ########### ########### 116 | 117 | 118 | #### Example 2: Plotting the Maze with Matplotlib 119 | 120 | Sometimes it is hard to see the finer points of a maze in plain text. You may want to see at a glance if the maze has unreachable sections, if it has loops or free walls, if it is obviously too easy, etcetera. 121 | 122 | import matplotlib.pyplot as plt 123 | 124 | def showPNG(grid): 125 | """Generate a simple image of the maze.""" 126 | plt.figure(figsize=(10, 5)) 127 | plt.imshow(grid, cmap=plt.cm.binary, interpolation='nearest') 128 | plt.xticks([]), plt.yticks([]) 129 | plt.show() 130 | 131 |  132 | 133 | 134 | #### Example 3: Displaying the Maze as CSS 135 | 136 | CSS and HTML are universal and easy to use. Here is a simple (if illegible) example to display your maze in CSS and HTML: 137 | 138 | def toHTML(grid, start, end, cell_size=10): 139 | row_max = grid.shape[0] 140 | col_max = grid.shape[1] 141 | 142 | html = '' + \ 144 | '
' + \ 145 | '' + \ 146 | '' + \ 157 | '