├── .gitignore ├── README.md ├── gym_maze ├── __init__.py └── envs │ ├── __init__.py │ ├── maze_env.py │ ├── maze_generator.py │ ├── maze_samples │ ├── maze2d_100x100.npy │ ├── maze2d_10x10.npy │ ├── maze2d_3x3.npy │ └── maze2d_5x5.npy │ └── maze_view_2d.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # IPython Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # dotenv 79 | .env 80 | 81 | # virtualenv 82 | venv/ 83 | ENV/ 84 | 85 | # Spyder project settings 86 | .spyderproject 87 | 88 | # Rope project settings 89 | .ropeproject 90 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # gym-maze 2 | 3 | A simple 2D maze environment where an agent (blue dot) finds its way from the top left corner (blue square) to the goal at the bottom right corner (red square). 4 | The objective is to find the shortest path from the start to the goal. 5 | 6 | ![Simple 2D maze environment](http://i.giphy.com/Ar3aKxkAAh3y0.gif) 7 | 8 | ### Action space 9 | The agent may only choose to go up, down, right, or left ("N", "S", "E", "W"). If the way is blocked, it will remain at the same the location. 10 | 11 | ### Observation space 12 | The observation space is the (x, y) coordinate of the agent. The top left cell is (0, 0). 13 | 14 | ### Reward 15 | A reward of 1 is given when the agent reaches the goal. For every step in the maze, the agent recieves a reward of -0.1/(number of cells). 16 | 17 | ### End condition 18 | The maze is reset when the agent reaches the goal. 19 | 20 | ## Maze Versions 21 | 22 | ### Pre-generated mazes 23 | * 3 cells x 3 cells: _MazeEnvSample3x3_ 24 | * 5 cells x 5 cells: _MazeEnvSample5x5_ 25 | * 10 cells x 10 cells: _MazeEnvSample10x10_ 26 | * 100 cells x 100 cells: _MazeEnvSample100x100_ 27 | 28 | ### Randomly generated mazes (same maze every epoch) 29 | * 3 cells x 3 cells: _MazeEnvRandom3x3_ 30 | * 5 cells x 5 cells: _MazeEnvRandom5x5_ 31 | * 10 cells x 10 cells: _MazeEnvRandom10x10_ 32 | * 100 cells x 100 cells: _MazeEnvRandom100x100_ 33 | 34 | ### Randomly generated mazes with portals and loops 35 | With loops, it means that there will be more than one possible path. 36 | The agent can also teleport from a portal to another portal of the same colour. 37 | * 10 cells x 10 cells: _MazeEnvRandom10x10Plus_ 38 | * 20 cells x 20 cells: _MazeEnvRandom20x20Plus_ 39 | * 30 cells x 30 cells: _MazeEnvRandom30x30Plus_ 40 | 41 | ## Installation 42 | It should work on both Python 2.7+ and 3.4+. It requires pygame and numpy. 43 | 44 | ```bash 45 | cd gym-maze 46 | python setup.py install 47 | ``` 48 | ## Examples 49 | An example of finding the shortest path through the maze using Q-learning can be found here: https://github.com/tuzzer/ai-gym/blob/master/maze_2d/maze_2d_q_learning.py 50 | 51 | ![Solving 20x20 maze with loops and portals using Q-Learning](http://i.giphy.com/rfazKQngdaja8.gif) 52 | 53 | -------------------------------------------------------------------------------- /gym_maze/__init__.py: -------------------------------------------------------------------------------- 1 | from gym.envs.registration import register 2 | 3 | 4 | register( 5 | id='maze-v0', 6 | entry_point='gym_maze.envs:MazeEnvSample5x5', 7 | max_episode_steps=2000, 8 | ) 9 | 10 | register( 11 | id='maze-sample-5x5-v0', 12 | entry_point='gym_maze.envs:MazeEnvSample5x5', 13 | max_episode_steps=2000, 14 | ) 15 | 16 | register( 17 | id='maze-random-5x5-v0', 18 | entry_point='gym_maze.envs:MazeEnvRandom5x5', 19 | max_episode_steps=2000, 20 | nondeterministic=True, 21 | ) 22 | 23 | register( 24 | id='maze-sample-10x10-v0', 25 | entry_point='gym_maze.envs:MazeEnvSample10x10', 26 | max_episode_steps=10000, 27 | ) 28 | 29 | register( 30 | id='maze-random-10x10-v0', 31 | entry_point='gym_maze.envs:MazeEnvRandom10x10', 32 | max_episode_steps=10000, 33 | nondeterministic=True, 34 | ) 35 | 36 | register( 37 | id='maze-sample-3x3-v0', 38 | entry_point='gym_maze.envs:MazeEnvSample3x3', 39 | max_episode_steps=1000, 40 | ) 41 | 42 | register( 43 | id='maze-random-3x3-v0', 44 | entry_point='gym_maze.envs:MazeEnvRandom3x3', 45 | max_episode_steps=1000, 46 | nondeterministic=True, 47 | ) 48 | 49 | 50 | register( 51 | id='maze-sample-100x100-v0', 52 | entry_point='gym_maze.envs:MazeEnvSample100x100', 53 | max_episode_steps=1000000, 54 | ) 55 | 56 | register( 57 | id='maze-random-100x100-v0', 58 | entry_point='gym_maze.envs:MazeEnvRandom100x100', 59 | max_episode_steps=1000000, 60 | nondeterministic=True, 61 | ) 62 | 63 | register( 64 | id='maze-random-10x10-plus-v0', 65 | entry_point='gym_maze.envs:MazeEnvRandom10x10Plus', 66 | max_episode_steps=1000000, 67 | nondeterministic=True, 68 | ) 69 | 70 | register( 71 | id='maze-random-20x20-plus-v0', 72 | entry_point='gym_maze.envs:MazeEnvRandom20x20Plus', 73 | max_episode_steps=1000000, 74 | nondeterministic=True, 75 | ) 76 | 77 | register( 78 | id='maze-random-30x30-plus-v0', 79 | entry_point='gym_maze.envs:MazeEnvRandom30x30Plus', 80 | max_episode_steps=1000000, 81 | nondeterministic=True, 82 | ) 83 | -------------------------------------------------------------------------------- /gym_maze/envs/__init__.py: -------------------------------------------------------------------------------- 1 | from gym_maze.envs.maze_env import * 2 | from gym_maze.envs.maze_view_2d import MazeView2D 3 | -------------------------------------------------------------------------------- /gym_maze/envs/maze_env.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | import gym 4 | from gym import error, spaces, utils 5 | from gym.utils import seeding 6 | from gym_maze.envs.maze_view_2d import MazeView2D 7 | 8 | 9 | class MazeEnv(gym.Env): 10 | metadata = { 11 | "render.modes": ["human", "rgb_array"], 12 | } 13 | 14 | ACTION = ["N", "S", "E", "W"] 15 | 16 | def __init__(self, maze_file=None, maze_size=None, mode=None, enable_render=True): 17 | 18 | self.viewer = None 19 | self.enable_render = enable_render 20 | 21 | if maze_file: 22 | self.maze_view = MazeView2D(maze_name="OpenAI Gym - Maze (%s)" % maze_file, 23 | maze_file_path=maze_file, 24 | screen_size=(640, 640), 25 | enable_render=enable_render) 26 | elif maze_size: 27 | if mode == "plus": 28 | has_loops = True 29 | num_portals = int(round(min(maze_size)/3)) 30 | else: 31 | has_loops = False 32 | num_portals = 0 33 | 34 | self.maze_view = MazeView2D(maze_name="OpenAI Gym - Maze (%d x %d)" % maze_size, 35 | maze_size=maze_size, screen_size=(640, 640), 36 | has_loops=has_loops, num_portals=num_portals, 37 | enable_render=enable_render) 38 | else: 39 | raise AttributeError("One must supply either a maze_file path (str) or the maze_size (tuple of length 2)") 40 | 41 | self.maze_size = self.maze_view.maze_size 42 | 43 | # forward or backward in each dimension 44 | self.action_space = spaces.Discrete(2*len(self.maze_size)) 45 | 46 | # observation is the x, y coordinate of the grid 47 | low = np.zeros(len(self.maze_size), dtype=int) 48 | high = np.array(self.maze_size, dtype=int) - np.ones(len(self.maze_size), dtype=int) 49 | self.observation_space = spaces.Box(low, high, dtype=np.int64) 50 | 51 | # initial condition 52 | self.state = None 53 | self.steps_beyond_done = None 54 | 55 | # Simulation related variables. 56 | self.seed() 57 | self.reset() 58 | 59 | # Just need to initialize the relevant attributes 60 | self.configure() 61 | 62 | def __del__(self): 63 | if self.enable_render is True: 64 | self.maze_view.quit_game() 65 | 66 | def configure(self, display=None): 67 | self.display = display 68 | 69 | def seed(self, seed=None): 70 | self.np_random, seed = seeding.np_random(seed) 71 | return [seed] 72 | 73 | def step(self, action): 74 | if isinstance(action, int): 75 | self.maze_view.move_robot(self.ACTION[action]) 76 | else: 77 | self.maze_view.move_robot(action) 78 | 79 | if np.array_equal(self.maze_view.robot, self.maze_view.goal): 80 | reward = 1 81 | done = True 82 | else: 83 | reward = -0.1/(self.maze_size[0]*self.maze_size[1]) 84 | done = False 85 | 86 | self.state = self.maze_view.robot 87 | 88 | info = {} 89 | 90 | return self.state, reward, done, info 91 | 92 | def reset(self): 93 | self.maze_view.reset_robot() 94 | self.state = np.zeros(2) 95 | self.steps_beyond_done = None 96 | self.done = False 97 | return self.state 98 | 99 | def is_game_over(self): 100 | return self.maze_view.game_over 101 | 102 | def render(self, mode="human", close=False): 103 | if close: 104 | self.maze_view.quit_game() 105 | 106 | return self.maze_view.update(mode) 107 | 108 | 109 | class MazeEnvSample5x5(MazeEnv): 110 | 111 | def __init__(self, enable_render=True): 112 | super(MazeEnvSample5x5, self).__init__(maze_file="maze2d_5x5.npy", enable_render=enable_render) 113 | 114 | 115 | class MazeEnvRandom5x5(MazeEnv): 116 | 117 | def __init__(self, enable_render=True): 118 | super(MazeEnvRandom5x5, self).__init__(maze_size=(5, 5), enable_render=enable_render) 119 | 120 | 121 | class MazeEnvSample10x10(MazeEnv): 122 | 123 | def __init__(self, enable_render=True): 124 | super(MazeEnvSample10x10, self).__init__(maze_file="maze2d_10x10.npy", enable_render=enable_render) 125 | 126 | 127 | class MazeEnvRandom10x10(MazeEnv): 128 | 129 | def __init__(self, enable_render=True): 130 | super(MazeEnvRandom10x10, self).__init__(maze_size=(10, 10), enable_render=enable_render) 131 | 132 | 133 | class MazeEnvSample3x3(MazeEnv): 134 | 135 | def __init__(self, enable_render=True): 136 | super(MazeEnvSample3x3, self).__init__(maze_file="maze2d_3x3.npy", enable_render=enable_render) 137 | 138 | 139 | class MazeEnvRandom3x3(MazeEnv): 140 | 141 | def __init__(self, enable_render=True): 142 | super(MazeEnvRandom3x3, self).__init__(maze_size=(3, 3), enable_render=enable_render) 143 | 144 | 145 | class MazeEnvSample100x100(MazeEnv): 146 | 147 | def __init__(self, enable_render=True): 148 | super(MazeEnvSample100x100, self).__init__(maze_file="maze2d_100x100.npy", enable_render=enable_render) 149 | 150 | 151 | class MazeEnvRandom100x100(MazeEnv): 152 | 153 | def __init__(self, enable_render=True): 154 | super(MazeEnvRandom100x100, self).__init__(maze_size=(100, 100), enable_render=enable_render) 155 | 156 | 157 | class MazeEnvRandom10x10Plus(MazeEnv): 158 | 159 | def __init__(self, enable_render=True): 160 | super(MazeEnvRandom10x10Plus, self).__init__(maze_size=(10, 10), mode="plus", enable_render=enable_render) 161 | 162 | 163 | class MazeEnvRandom20x20Plus(MazeEnv): 164 | 165 | def __init__(self, enable_render=True): 166 | super(MazeEnvRandom20x20Plus, self).__init__(maze_size=(20, 20), mode="plus", enable_render=enable_render) 167 | 168 | 169 | class MazeEnvRandom30x30Plus(MazeEnv): 170 | def __init__(self, enable_render=True): 171 | super(MazeEnvRandom30x30Plus, self).__init__(maze_size=(30, 30), mode="plus", enable_render=enable_render) 172 | -------------------------------------------------------------------------------- /gym_maze/envs/maze_generator.py: -------------------------------------------------------------------------------- 1 | import os 2 | from gym_maze.envs.maze_view_2d import Maze 3 | 4 | if __name__ == "__main__": 5 | 6 | # check if the folder "maze_samples" exists in the current working directory 7 | dir_name = os.path.join(os.getcwd(), "maze_samples") 8 | if not os.path.exists(dir_name): 9 | # create it if it doesn't 10 | os.mkdir(dir_name) 11 | 12 | # increment number until it finds a name that is not being used already (max maze_999) 13 | maze_path = None 14 | for i in range(1, 1000): 15 | maze_name = "maze2d_%03d.npy" % i 16 | maze_path = os.path.join(dir_name, maze_name) 17 | if not os.path.exists(maze_path): 18 | break 19 | if i == 999: 20 | raise ValueError("There are already 999 mazes in the %s." % dir_name) 21 | 22 | maze = Maze(maze_size=(5, 5)) 23 | maze.save_maze(maze_path) 24 | print("New maze generated and saved at %s." % maze_path) 25 | 26 | -------------------------------------------------------------------------------- /gym_maze/envs/maze_samples/maze2d_100x100.npy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MattChanTK/gym-maze/83176811b49b5538a6213520612f44fb1bc49114/gym_maze/envs/maze_samples/maze2d_100x100.npy -------------------------------------------------------------------------------- /gym_maze/envs/maze_samples/maze2d_10x10.npy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MattChanTK/gym-maze/83176811b49b5538a6213520612f44fb1bc49114/gym_maze/envs/maze_samples/maze2d_10x10.npy -------------------------------------------------------------------------------- /gym_maze/envs/maze_samples/maze2d_3x3.npy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MattChanTK/gym-maze/83176811b49b5538a6213520612f44fb1bc49114/gym_maze/envs/maze_samples/maze2d_3x3.npy -------------------------------------------------------------------------------- /gym_maze/envs/maze_samples/maze2d_5x5.npy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MattChanTK/gym-maze/83176811b49b5538a6213520612f44fb1bc49114/gym_maze/envs/maze_samples/maze2d_5x5.npy -------------------------------------------------------------------------------- /gym_maze/envs/maze_view_2d.py: -------------------------------------------------------------------------------- 1 | import pygame 2 | import random 3 | import numpy as np 4 | import os 5 | 6 | 7 | class MazeView2D: 8 | 9 | def __init__(self, maze_name="Maze2D", maze_file_path=None, 10 | maze_size=(30, 30), screen_size=(600, 600), 11 | has_loops=False, num_portals=0, enable_render=True): 12 | 13 | # PyGame configurations 14 | pygame.init() 15 | pygame.display.set_caption(maze_name) 16 | self.clock = pygame.time.Clock() 17 | self.__game_over = False 18 | self.__enable_render = enable_render 19 | 20 | # Load a maze 21 | if maze_file_path is None: 22 | self.__maze = Maze(maze_size=maze_size, has_loops=has_loops, num_portals=num_portals) 23 | else: 24 | if not os.path.exists(maze_file_path): 25 | dir_path = os.path.dirname(os.path.abspath(__file__)) 26 | rel_path = os.path.join(dir_path, "maze_samples", maze_file_path) 27 | if os.path.exists(rel_path): 28 | maze_file_path = rel_path 29 | else: 30 | raise FileExistsError("Cannot find %s." % maze_file_path) 31 | self.__maze = Maze(maze_cells=Maze.load_maze(maze_file_path)) 32 | 33 | self.maze_size = self.__maze.maze_size 34 | if self.__enable_render is True: 35 | # to show the right and bottom border 36 | self.screen = pygame.display.set_mode(screen_size) 37 | self.__screen_size = tuple(map(sum, zip(screen_size, (-1, -1)))) 38 | 39 | # Set the starting point 40 | self.__entrance = np.zeros(2, dtype=int) 41 | 42 | # Set the Goal 43 | self.__goal = np.array(self.maze_size) - np.array((1, 1)) 44 | 45 | # Create the Robot 46 | self.__robot = self.entrance 47 | 48 | if self.__enable_render is True: 49 | # Create a background 50 | self.background = pygame.Surface(self.screen.get_size()).convert() 51 | self.background.fill((255, 255, 255)) 52 | 53 | # Create a layer for the maze 54 | self.maze_layer = pygame.Surface(self.screen.get_size()).convert_alpha() 55 | self.maze_layer.fill((0, 0, 0, 0,)) 56 | 57 | # show the maze 58 | self.__draw_maze() 59 | 60 | # show the portals 61 | self.__draw_portals() 62 | 63 | # show the robot 64 | self.__draw_robot() 65 | 66 | # show the entrance 67 | self.__draw_entrance() 68 | 69 | # show the goal 70 | self.__draw_goal() 71 | 72 | def update(self, mode="human"): 73 | try: 74 | img_output = self.__view_update(mode) 75 | self.__controller_update() 76 | except Exception as e: 77 | self.__game_over = True 78 | self.quit_game() 79 | raise e 80 | else: 81 | return img_output 82 | 83 | def quit_game(self): 84 | try: 85 | self.__game_over = True 86 | if self.__enable_render is True: 87 | pygame.display.quit() 88 | pygame.quit() 89 | except Exception: 90 | pass 91 | 92 | def move_robot(self, dir): 93 | if dir not in self.__maze.COMPASS.keys(): 94 | raise ValueError("dir cannot be %s. The only valid dirs are %s." 95 | % (str(dir), str(self.__maze.COMPASS.keys()))) 96 | 97 | if self.__maze.is_open(self.__robot, dir): 98 | 99 | # update the drawing 100 | self.__draw_robot(transparency=0) 101 | 102 | # move the robot 103 | self.__robot += np.array(self.__maze.COMPASS[dir]) 104 | # if it's in a portal afterward 105 | if self.maze.is_portal(self.robot): 106 | self.__robot = np.array(self.maze.get_portal(tuple(self.robot)).teleport(tuple(self.robot))) 107 | self.__draw_robot(transparency=255) 108 | 109 | def reset_robot(self): 110 | 111 | self.__draw_robot(transparency=0) 112 | self.__robot = np.zeros(2, dtype=int) 113 | self.__draw_robot(transparency=255) 114 | 115 | def __controller_update(self): 116 | if not self.__game_over: 117 | for event in pygame.event.get(): 118 | if event.type == pygame.QUIT: 119 | self.__game_over = True 120 | self.quit_game() 121 | 122 | def __view_update(self, mode="human"): 123 | if not self.__game_over: 124 | # update the robot's position 125 | self.__draw_entrance() 126 | self.__draw_goal() 127 | self.__draw_portals() 128 | self.__draw_robot() 129 | 130 | 131 | # update the screen 132 | self.screen.blit(self.background, (0, 0)) 133 | self.screen.blit(self.maze_layer,(0, 0)) 134 | 135 | if mode == "human": 136 | pygame.display.flip() 137 | 138 | return np.flipud(np.rot90(pygame.surfarray.array3d(pygame.display.get_surface()))) 139 | 140 | def __draw_maze(self): 141 | 142 | if self.__enable_render is False: 143 | return 144 | 145 | line_colour = (0, 0, 0, 255) 146 | 147 | # drawing the horizontal lines 148 | for y in range(self.maze.MAZE_H + 1): 149 | pygame.draw.line(self.maze_layer, line_colour, (0, y * self.CELL_H), 150 | (self.SCREEN_W, y * self.CELL_H)) 151 | 152 | # drawing the vertical lines 153 | for x in range(self.maze.MAZE_W + 1): 154 | pygame.draw.line(self.maze_layer, line_colour, (x * self.CELL_W, 0), 155 | (x * self.CELL_W, self.SCREEN_H)) 156 | 157 | # breaking the walls 158 | for x in range(len(self.maze.maze_cells)): 159 | for y in range (len(self.maze.maze_cells[x])): 160 | # check the which walls are open in each cell 161 | walls_status = self.maze.get_walls_status(self.maze.maze_cells[x, y]) 162 | dirs = "" 163 | for dir, open in walls_status.items(): 164 | if open: 165 | dirs += dir 166 | self.__cover_walls(x, y, dirs) 167 | 168 | def __cover_walls(self, x, y, dirs, colour=(0, 0, 255, 15)): 169 | 170 | if self.__enable_render is False: 171 | return 172 | 173 | dx = x * self.CELL_W 174 | dy = y * self.CELL_H 175 | 176 | if not isinstance(dirs, str): 177 | raise TypeError("dirs must be a str.") 178 | 179 | for dir in dirs: 180 | if dir == "S": 181 | line_head = (dx + 1, dy + self.CELL_H) 182 | line_tail = (dx + self.CELL_W - 1, dy + self.CELL_H) 183 | elif dir == "N": 184 | line_head = (dx + 1, dy) 185 | line_tail = (dx + self.CELL_W - 1, dy) 186 | elif dir == "W": 187 | line_head = (dx, dy + 1) 188 | line_tail = (dx, dy + self.CELL_H - 1) 189 | elif dir == "E": 190 | line_head = (dx + self.CELL_W, dy + 1) 191 | line_tail = (dx + self.CELL_W, dy + self.CELL_H - 1) 192 | else: 193 | raise ValueError("The only valid directions are (N, S, E, W).") 194 | 195 | pygame.draw.line(self.maze_layer, colour, line_head, line_tail) 196 | 197 | def __draw_robot(self, colour=(0, 0, 150), transparency=255): 198 | 199 | if self.__enable_render is False: 200 | return 201 | 202 | x = int(self.__robot[0] * self.CELL_W + self.CELL_W * 0.5 + 0.5) 203 | y = int(self.__robot[1] * self.CELL_H + self.CELL_H * 0.5 + 0.5) 204 | r = int(min(self.CELL_W, self.CELL_H)/5 + 0.5) 205 | 206 | pygame.draw.circle(self.maze_layer, colour + (transparency,), (x, y), r) 207 | 208 | def __draw_entrance(self, colour=(0, 0, 150), transparency=235): 209 | 210 | self.__colour_cell(self.entrance, colour=colour, transparency=transparency) 211 | 212 | def __draw_goal(self, colour=(150, 0, 0), transparency=235): 213 | 214 | self.__colour_cell(self.goal, colour=colour, transparency=transparency) 215 | 216 | def __draw_portals(self, transparency=160): 217 | 218 | if self.__enable_render is False: 219 | return 220 | 221 | colour_range = np.linspace(0, 255, len(self.maze.portals), dtype=int) 222 | colour_i = 0 223 | for portal in self.maze.portals: 224 | colour = ((100 - colour_range[colour_i])% 255, colour_range[colour_i], 0) 225 | colour_i += 1 226 | for location in portal.locations: 227 | self.__colour_cell(location, colour=colour, transparency=transparency) 228 | 229 | def __colour_cell(self, cell, colour, transparency): 230 | 231 | if self.__enable_render is False: 232 | return 233 | 234 | if not (isinstance(cell, (list, tuple, np.ndarray)) and len(cell) == 2): 235 | raise TypeError("cell must a be a tuple, list, or numpy array of size 2") 236 | 237 | x = int(cell[0] * self.CELL_W + 0.5 + 1) 238 | y = int(cell[1] * self.CELL_H + 0.5 + 1) 239 | w = int(self.CELL_W + 0.5 - 1) 240 | h = int(self.CELL_H + 0.5 - 1) 241 | pygame.draw.rect(self.maze_layer, colour + (transparency,), (x, y, w, h)) 242 | 243 | @property 244 | def maze(self): 245 | return self.__maze 246 | 247 | @property 248 | def robot(self): 249 | return self.__robot 250 | 251 | @property 252 | def entrance(self): 253 | return self.__entrance 254 | 255 | @property 256 | def goal(self): 257 | return self.__goal 258 | 259 | @property 260 | def game_over(self): 261 | return self.__game_over 262 | 263 | @property 264 | def SCREEN_SIZE(self): 265 | return tuple(self.__screen_size) 266 | 267 | @property 268 | def SCREEN_W(self): 269 | return int(self.SCREEN_SIZE[0]) 270 | 271 | @property 272 | def SCREEN_H(self): 273 | return int(self.SCREEN_SIZE[1]) 274 | 275 | @property 276 | def CELL_W(self): 277 | return float(self.SCREEN_W) / float(self.maze.MAZE_W) 278 | 279 | @property 280 | def CELL_H(self): 281 | return float(self.SCREEN_H) / float(self.maze.MAZE_H) 282 | 283 | 284 | class Maze: 285 | 286 | COMPASS = { 287 | "N": (0, -1), 288 | "E": (1, 0), 289 | "S": (0, 1), 290 | "W": (-1, 0) 291 | } 292 | 293 | def __init__(self, maze_cells=None, maze_size=(10,10), has_loops=True, num_portals=0): 294 | 295 | # maze member variables 296 | self.maze_cells = maze_cells 297 | self.has_loops = has_loops 298 | self.__portals_dict = dict() 299 | self.__portals = [] 300 | self.num_portals = num_portals 301 | 302 | # Use existing one if exists 303 | if self.maze_cells is not None: 304 | if isinstance(self.maze_cells, (np.ndarray, np.generic)) and len(self.maze_cells.shape) == 2: 305 | self.maze_size = tuple(maze_cells.shape) 306 | else: 307 | raise ValueError("maze_cells must be a 2D NumPy array.") 308 | # Otherwise, generate a random one 309 | else: 310 | # maze's configuration parameters 311 | if not (isinstance(maze_size, (list, tuple)) and len(maze_size) == 2): 312 | raise ValueError("maze_size must be a tuple: (width, height).") 313 | self.maze_size = maze_size 314 | 315 | self._generate_maze() 316 | 317 | def save_maze(self, file_path): 318 | 319 | if not isinstance(file_path, str): 320 | raise TypeError("Invalid file_path. It must be a str.") 321 | 322 | if not os.path.exists(os.path.dirname(file_path)): 323 | raise ValueError("Cannot find the directory for %s." % file_path) 324 | 325 | else: 326 | np.save(file_path, self.maze_cells, allow_pickle=False, fix_imports=True) 327 | 328 | @classmethod 329 | def load_maze(cls, file_path): 330 | 331 | if not isinstance(file_path, str): 332 | raise TypeError("Invalid file_path. It must be a str.") 333 | 334 | if not os.path.exists(file_path): 335 | raise ValueError("Cannot find %s." % file_path) 336 | 337 | else: 338 | return np.load(file_path, allow_pickle=False, fix_imports=True) 339 | 340 | def _generate_maze(self): 341 | 342 | # list of all cell locations 343 | self.maze_cells = np.zeros(self.maze_size, dtype=int) 344 | 345 | # Initializing constants and variables needed for maze generation 346 | current_cell = (random.randint(0, self.MAZE_W-1), random.randint(0, self.MAZE_H-1)) 347 | num_cells_visited = 1 348 | cell_stack = [current_cell] 349 | 350 | # Continue until all cells are visited 351 | while cell_stack: 352 | 353 | # restart from a cell from the cell stack 354 | current_cell = cell_stack.pop() 355 | x0, y0 = current_cell 356 | 357 | # find neighbours of the current cells that actually exist 358 | neighbours = dict() 359 | for dir_key, dir_val in self.COMPASS.items(): 360 | x1 = x0 + dir_val[0] 361 | y1 = y0 + dir_val[1] 362 | # if cell is within bounds 363 | if 0 <= x1 < self.MAZE_W and 0 <= y1 < self.MAZE_H: 364 | # if all four walls still exist 365 | if self.all_walls_intact(self.maze_cells[x1, y1]): 366 | #if self.num_walls_broken(self.maze_cells[x1, y1]) <= 1: 367 | neighbours[dir_key] = (x1, y1) 368 | 369 | # if there is a neighbour 370 | if neighbours: 371 | # select a random neighbour 372 | dir = random.choice(tuple(neighbours.keys())) 373 | x1, y1 = neighbours[dir] 374 | 375 | # knock down the wall between the current cell and the selected neighbour 376 | self.maze_cells[x1, y1] = self.__break_walls(self.maze_cells[x1, y1], self.__get_opposite_wall(dir)) 377 | 378 | # push the current cell location to the stack 379 | cell_stack.append(current_cell) 380 | 381 | # make the this neighbour cell the current cell 382 | cell_stack.append((x1, y1)) 383 | 384 | # increment the visited cell count 385 | num_cells_visited += 1 386 | 387 | if self.has_loops: 388 | self.__break_random_walls(0.2) 389 | 390 | if self.num_portals > 0: 391 | self.__set_random_portals(num_portal_sets=self.num_portals, set_size=2) 392 | 393 | def __break_random_walls(self, percent): 394 | # find some random cells to break 395 | num_cells = int(round(self.MAZE_H*self.MAZE_W*percent)) 396 | cell_ids = random.sample(range(self.MAZE_W*self.MAZE_H), num_cells) 397 | 398 | # for each of those walls 399 | for cell_id in cell_ids: 400 | x = cell_id % self.MAZE_H 401 | y = int(cell_id/self.MAZE_H) 402 | 403 | # randomize the compass order 404 | dirs = random.sample(list(self.COMPASS.keys()), len(self.COMPASS)) 405 | for dir in dirs: 406 | # break the wall if it's not already open 407 | if self.is_breakable((x, y), dir): 408 | self.maze_cells[x, y] = self.__break_walls(self.maze_cells[x, y], dir) 409 | break 410 | 411 | def __set_random_portals(self, num_portal_sets, set_size=2): 412 | # find some random cells to break 413 | num_portal_sets = int(num_portal_sets) 414 | set_size = int(set_size) 415 | 416 | # limit the maximum number of portal sets to the number of cells available. 417 | max_portal_sets = int(self.MAZE_W * self.MAZE_H / set_size) 418 | num_portal_sets = min(max_portal_sets, num_portal_sets) 419 | 420 | # the first and last cells are reserved 421 | cell_ids = random.sample(range(1, self.MAZE_W * self.MAZE_H - 1), num_portal_sets*set_size) 422 | 423 | for i in range(num_portal_sets): 424 | # sample the set_size number of sell 425 | portal_cell_ids = random.sample(cell_ids, set_size) 426 | portal_locations = [] 427 | for portal_cell_id in portal_cell_ids: 428 | # remove the cell from the set of potential cell_ids 429 | cell_ids.pop(cell_ids.index(portal_cell_id)) 430 | # convert portal ids to location 431 | x = portal_cell_id % self.MAZE_H 432 | y = int(portal_cell_id / self.MAZE_H) 433 | portal_locations.append((x,y)) 434 | # append the new portal to the maze 435 | portal = Portal(*portal_locations) 436 | self.__portals.append(portal) 437 | 438 | # create a dictionary of portals 439 | for portal_location in portal_locations: 440 | self.__portals_dict[portal_location] = portal 441 | 442 | def is_open(self, cell_id, dir): 443 | # check if it would be out-of-bound 444 | x1 = cell_id[0] + self.COMPASS[dir][0] 445 | y1 = cell_id[1] + self.COMPASS[dir][1] 446 | 447 | # if cell is still within bounds after the move 448 | if self.is_within_bound(x1, y1): 449 | # check if the wall is opened 450 | this_wall = bool(self.get_walls_status(self.maze_cells[cell_id[0], cell_id[1]])[dir]) 451 | other_wall = bool(self.get_walls_status(self.maze_cells[x1, y1])[self.__get_opposite_wall(dir)]) 452 | return this_wall or other_wall 453 | return False 454 | 455 | def is_breakable(self, cell_id, dir): 456 | # check if it would be out-of-bound 457 | x1 = cell_id[0] + self.COMPASS[dir][0] 458 | y1 = cell_id[1] + self.COMPASS[dir][1] 459 | 460 | return not self.is_open(cell_id, dir) and self.is_within_bound(x1, y1) 461 | 462 | def is_within_bound(self, x, y): 463 | # true if cell is still within bounds after the move 464 | return 0 <= x < self.MAZE_W and 0 <= y < self.MAZE_H 465 | 466 | def is_portal(self, cell): 467 | return tuple(cell) in self.__portals_dict 468 | 469 | @property 470 | def portals(self): 471 | return tuple(self.__portals) 472 | 473 | def get_portal(self, cell): 474 | if cell in self.__portals_dict: 475 | return self.__portals_dict[cell] 476 | return None 477 | 478 | @property 479 | def MAZE_W(self): 480 | return int(self.maze_size[0]) 481 | 482 | @property 483 | def MAZE_H(self): 484 | return int(self.maze_size[1]) 485 | 486 | @classmethod 487 | def get_walls_status(cls, cell): 488 | walls = { 489 | "N" : (cell & 0x1) >> 0, 490 | "E" : (cell & 0x2) >> 1, 491 | "S" : (cell & 0x4) >> 2, 492 | "W" : (cell & 0x8) >> 3, 493 | } 494 | return walls 495 | 496 | @classmethod 497 | def all_walls_intact(cls, cell): 498 | return cell & 0xF == 0 499 | 500 | @classmethod 501 | def num_walls_broken(cls, cell): 502 | walls = cls.get_walls_status(cell) 503 | num_broken = 0 504 | for wall_broken in walls.values(): 505 | num_broken += wall_broken 506 | return num_broken 507 | 508 | @classmethod 509 | def __break_walls(cls, cell, dirs): 510 | if "N" in dirs: 511 | cell |= 0x1 512 | if "E" in dirs: 513 | cell |= 0x2 514 | if "S" in dirs: 515 | cell |= 0x4 516 | if "W" in dirs: 517 | cell |= 0x8 518 | return cell 519 | 520 | @classmethod 521 | def __get_opposite_wall(cls, dirs): 522 | 523 | if not isinstance(dirs, str): 524 | raise TypeError("dirs must be a str.") 525 | 526 | opposite_dirs = "" 527 | 528 | for dir in dirs: 529 | if dir == "N": 530 | opposite_dir = "S" 531 | elif dir == "S": 532 | opposite_dir = "N" 533 | elif dir == "E": 534 | opposite_dir = "W" 535 | elif dir == "W": 536 | opposite_dir = "E" 537 | else: 538 | raise ValueError("The only valid directions are (N, S, E, W).") 539 | 540 | opposite_dirs += opposite_dir 541 | 542 | return opposite_dirs 543 | 544 | class Portal: 545 | 546 | def __init__(self, *locations): 547 | 548 | self.__locations = [] 549 | for location in locations: 550 | if isinstance(location, (tuple, list)): 551 | self.__locations.append(tuple(location)) 552 | else: 553 | raise ValueError("location must be a list or a tuple.") 554 | 555 | def teleport(self, cell): 556 | if cell in self.locations: 557 | return self.locations[(self.locations.index(cell) + 1) % len(self.locations)] 558 | return cell 559 | 560 | def get_index(self, cell): 561 | return self.locations.index(cell) 562 | 563 | @property 564 | def locations(self): 565 | return self.__locations 566 | 567 | 568 | if __name__ == "__main__": 569 | 570 | maze = MazeView2D(screen_size= (500, 500), maze_size=(10,10)) 571 | maze.update() 572 | input("Enter any key to quit.") 573 | 574 | 575 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup(name="gym_maze", 4 | version="0.4", 5 | url="https://github.com/tuzzer/gym-maze", 6 | author="Matthew T.K. Chan", 7 | license="MIT", 8 | packages=["gym_maze", "gym_maze.envs"], 9 | package_data = { 10 | "gym_maze.envs": ["maze_samples/*.npy"] 11 | }, 12 | install_requires = ["gym", "pygame", "numpy"] 13 | ) 14 | --------------------------------------------------------------------------------