diff --git a/docs/resources/images/focused100x100_0.png b/docs/resources/images/focused100x100_0.png new file mode 100644 index 0000000..e84f4eb Binary files /dev/null and b/docs/resources/images/focused100x100_0.png differ diff --git a/docs/resources/images/maze100x100_0.png b/docs/resources/images/maze100x100_0.png new file mode 100644 index 0000000..fa2261d Binary files /dev/null and b/docs/resources/images/maze100x100_0.png differ diff --git a/docs/resources/images/perlin100x100_0.png b/docs/resources/images/perlin100x100_0.png new file mode 100644 index 0000000..6ee1a4a Binary files /dev/null and b/docs/resources/images/perlin100x100_0.png differ diff --git a/docs/resources/scripts/tutorial.py b/docs/resources/scripts/tutorial.py index b43fd11..a278b5e 100644 --- a/docs/resources/scripts/tutorial.py +++ b/docs/resources/scripts/tutorial.py @@ -2,9 +2,9 @@ ##################################################################### from sIArena.terrain.Terrain import Coordinate, Terrain, Path -from sIArena.terrain.generator.PernilGenerator import PernilGenerator +from sIArena.terrain.generator.PerlinGenerator import PerlinGenerator -terrain = PernilGenerator().generate_random_terrain( +terrain = PerlinGenerator().generate_random_terrain( n=25, m=25, min_height=0, diff --git a/docs/rst/getting_started/tldr.rst b/docs/rst/getting_started/tldr.rst index 4761e0f..6164923 100644 --- a/docs/rst/getting_started/tldr.rst +++ b/docs/rst/getting_started/tldr.rst @@ -44,9 +44,9 @@ Use the following code changing some parameters: from sIArena.terrain.generator.Generator import TerrainGenerator from sIArena.terrain.generator.FocusedGenerator import FocusedGenerator - from sIArena.terrain.generator.PernilGenerator import PernilGenerator + from sIArena.terrain.generator.PerlinGenerator import PerlinGenerator - terrain = PernilGenerator().generate_random_terrain( + terrain = PerlinGenerator().generate_random_terrain( n=20, m=20, min_height=0, diff --git a/docs/rst/modules/elements/example.rst b/docs/rst/modules/elements/example.rst index f5baadc..1dfabe0 100644 --- a/docs/rst/modules/elements/example.rst +++ b/docs/rst/modules/elements/example.rst @@ -54,7 +54,6 @@ The following snippet shows the different features of the elements: terrain.get_neighbors((0, 0)) # Output: [(0, 1), (1, 0)] terrain.get_neighbors((1, 1)) # Output: [(0, 1), (1, 0), (1, 2), (2, 1)] - # PATH ####### @@ -64,5 +63,8 @@ The following snippet shows the different features of the elements: # Check the path is complete terrain.is_valid_path(path, terrain) # Output: True + # Check the path is complete + terrain.why_complete_path(path, terrain) # Output: True, str + # Check the path cost terrain.get_path_cost(path, terrain) # Output: 12 diff --git a/docs/rst/modules/elements/path.rst b/docs/rst/modules/elements/path.rst index eb4a679..3d41500 100644 --- a/docs/rst/modules/elements/path.rst +++ b/docs/rst/modules/elements/path.rst @@ -26,6 +26,7 @@ In order to be **complete**, the path must be valid and: - The first coordinate must be the ``origin`` point of the terrain. - The last coordinate must be the ``destination`` point of the terrain. +- In case of multiple destinations, the path must pass through all of them (order is not important). In Python, the Path is represented as a list of coordinates: diff --git a/docs/rst/modules/elements/terrain.rst b/docs/rst/modules/elements/terrain.rst index 1cd1482..d268b41 100644 --- a/docs/rst/modules/elements/terrain.rst +++ b/docs/rst/modules/elements/terrain.rst @@ -67,6 +67,8 @@ Methods - ``path``: ``Path`` - ``is_complete_path``: returns ``True`` if the given Path is valid and complete. - ``path``: ``Path`` +- ``why_complete_path``: returns the same value as ``is_complete_path`` and also retrieves a string with information why the path is not complete (if this is the case). + - ``path``: ``Path`` *Some of this methods use the element* :ref:`elements_path` *that is seeing afterwards.* @@ -148,3 +150,73 @@ In order to learn how to visualize a 2D plot of the terrain, please refer to the .. image:: /resources/images/3dplot_5_5.png In order to learn how to visualize a 3D plot of the terrain, please refer to the :ref:`plotting_3d` section. + + +Multiple Destinations Terrain +----------------------------- + +There is other class for Terrain that is called ``MultipleDestinationTerrain``. +This class allows to have multiple destinations in the terrain. +This means that the path must pass through all of them in order to be considered complete. +The destinations are not sorted, so they can be visited in any order. + +.. code-block:: python + + from sIArena.terrain.Terrain import MultipleDestinationTerrain + + +The use and methods of this class are similar to ``Terrain`` ones. +It changes: + +- The argument ``destination`` in the constructor is now a set of ``Coordinate``. +- The method ``is_complete_path`` now checks if the path passes through all the destinations. +- To get the destinations, use the attribute ``destinations``, that is a set of ``Coordinate``. + +Example on how to create a ``MultipleDestinationTerrain``: + +.. code-block:: python + + from sIArena.terrain.Terrain import MultipleDestinationTerrain + from sIArena.terrain.Coordinate import Coordinate + + matrix = np.array(...) + destinations = {Coordinate(4,4), Coordinate(0,4)} + # It uses the top-left cell as origin by default + terrain = MultipleDestinationTerrain(matrix, destination=destinations) + + # To get the destinations of the terrain + destinations = terrain.destinations + + +Sequential Destinations Terrain +------------------------------- + +There is other class for Terrain that is called ``SequentialDestinationTerrain``. +This class have multiple destinations, but in this case the path must pass through them in the same order as they are provided. + +.. code-block:: python + + from sIArena.terrain.Terrain import SequentialDestinationTerrain + + +The use and methods of this class are similar to ``Terrain`` ones. +It changes: + +- The argument ``destination`` in the constructor is now a list of ``Coordinate``. +- The method ``is_complete_path`` now checks if the path passes through all the destinations in the same order as they are provided. +- To get the destinations, use the attribute ``destinations``, that is a list of ``Coordinate``. + +Example on how to create a ``SequentialDestinationTerrain``: + +.. code-block:: python + + from sIArena.terrain.Terrain import SequentialDestinationTerrain + from sIArena.terrain.Coordinate import Coordinate + + matrix = np.array(...) + destinations = [Coordinate(4,4), Coordinate(0,4)] + # It uses the top-left cell as origin by default + terrain = SequentialDestinationTerrain(matrix, destination=destinations) + + # To get the destinations of the terrain + destinations = terrain.destinations diff --git a/docs/rst/modules/generation.rst b/docs/rst/modules/generation.rst index 2642b41..10f3ae6 100644 --- a/docs/rst/modules/generation.rst +++ b/docs/rst/modules/generation.rst @@ -4,16 +4,16 @@ Generate Terrain ################ -.. warning:: - - Coming soon. - TODO - .. contents:: :local: :backlinks: none :depth: 2 +There are several classes that help to generate a random terrain so the user does not have to create the matrix manually. + +The class ``Generator`` has a function ``generate_random_terrain`` that create an object of type ``Terrain`` (or ``DestinationSetTerrain`` if set). +These are the arguments for such function (some arguments are not used for different Generators): + - ``n: int`` number of rows - ``m: int`` number of columns - ``min_height: int = 0`` minimum height of the terrain @@ -23,3 +23,67 @@ Generate Terrain - ``seed: int = None`` seed for the random number generator - ``origin: Coordinate = None`` origin of the terrain - ``destination: Coordinate = None`` destination of the terrain +- ``terrain_ctor: Terrain = Terrain`` whether to use ``Terrain``or ``DestinationSetTerrain`` +- ``cost_function: callable = None`` cost function for the terrain (if None use default) + +There exist different generators that create the random matrix from different criteria: + +.. code-block:: python + + from sIArena.terrain.generator.FocusedGenerator import FocusedGenerator + from sIArena.terrain.generator.PerlinGenerator import PerlinGenerator + from sIArena.terrain.generator.MazeGenerator import MazeGenerator + +In order to generate a terrain, the user must create a generator object and call the function ``generate_random_terrain``: + +.. code-block:: python + + generator = FocusedGenerator() + terrain = generator.generate_random_terrain(n=10, m=10) + + +Focused Generator +================= + +This generator generates the map from top-left corner to bottom-right corner. +It generates each cell depending on the contiguous cells and a distribution probability. + +It tends to create very craggy and with diagonal mountains. + +.. code-block:: python + + terrain = FocusedGenerator().generate_random_terrain(n=100, m=100, seed=0) + +.. image:: /resources/images/focused100x100_0.png + + +Perlin Generator +================ + +This generator uses perlin noise to generate the terrain. + +It tends to create smooth terrains with some hills and valleys. + +.. code-block:: python + + terrain = PerlinGenerator().generate_random_terrain(n=100, m=100, seed=0) + +.. image:: /resources/images/perlin100x100_0.png + + + +Maze Generator +============== + +This generator creates a maze. +This is, it creates a terrain with 1 width valley and 1 width very high wall. +The valley connects the whole map, so the terrain can be walked through without climbing any wall. + +The origin and destination must be set afterwards. +It is assured to connect every valley point, and the top-left corner and bottom-right corner are always in a valley. + +.. code-block:: python + + terrain = MazeGenerator().generate_random_terrain(n=100, m=100, seed=0) + +.. image:: /resources/images/maze100x100_0.png diff --git a/docs/rst/modules/measure.rst b/docs/rst/modules/measure.rst index 013e55b..cca7a6c 100644 --- a/docs/rst/modules/measure.rst +++ b/docs/rst/modules/measure.rst @@ -4,12 +4,38 @@ Measure tools ############# -.. warning:: - - Coming soon. - TODO - .. contents:: :local: :backlinks: none :depth: 2 + +There is a built-in function prepared to measure the time consumption of a path-finding algorithm. + +The function ``measure_function`` receives a function that generates a path given a terrain. +The argument ``search_function`` must be a function that receives a terrain and returns a path. + +.. code-block:: python + + from sIArena.measurements.measurements import measure_function + + def search_function(terrain: Terrain) -> Path: + # Your path-finding algorithm here + return path + + time = measure_function(search_function) + +This function receives the following arguments: + + search_function, + terrain: Terrain, + iterations: int = 1, + debug: bool = False, + max_seconds: float = 60*5 + +- ``search_function``: The function that generates a path given a terrain. +- ``terrain: Terrain``: The terrain to be used in the path-finding algorithm. +- ``iterations: int = 1``: The number of times the function will be executed. The default value is 1. This is used to get the average time of the function. +- ``debug: bool = False``: If True, the function will print the time of each iteration. +- ``max_seconds: float = 60*5``: The maximum time that the function will run. + +The function will fail with an exception if the path generated by the function is not correct or if the function takes more than the maximum time to run. diff --git a/docs/spelling/spelling_wordlist.txt b/docs/spelling/spelling_wordlist.txt index 174c74d..a03cf63 100644 --- a/docs/spelling/spelling_wordlist.txt +++ b/docs/spelling/spelling_wordlist.txt @@ -1,3 +1,4 @@ Colab IArena sIArena +perlin diff --git a/resources/measurement_template.ipynb b/resources/measurement_template.ipynb index 71a8402..7842b04 100644 --- a/resources/measurement_template.ipynb +++ b/resources/measurement_template.ipynb @@ -20,7 +20,7 @@ "\n", "from sIArena.terrain.plot.plot_2D import plot_terrain_2D\n", "from sIArena.terrain.plot.plot_3D import plot_terrain_3D\n", - "from sIArena.terrain.generator.PernilGenerator import PernilGenerator\n", + "from sIArena.terrain.generator.PerlinGenerator import PerlinGenerator\n", "\n", "N = 50\n", "M = 50\n", @@ -32,7 +32,7 @@ "ORIGIN = None\n", "DESTINATION = None\n", "\n", - "terrain = PernilGenerator().generate_random_terrain(\n", + "terrain = PerlinGenerator().generate_random_terrain(\n", " n=N,\n", " m=M,\n", " min_height=MIN_HEIGHT,\n", diff --git a/src/sIArena/measurements/measurements.py b/src/sIArena/measurements/measurements.py index b3b3aae..14f8def 100644 --- a/src/sIArena/measurements/measurements.py +++ b/src/sIArena/measurements/measurements.py @@ -64,8 +64,9 @@ def func_wrapper(func, terrain, result): else: path = result[0] - if not terrain.is_complete_path(path): - raise ValueError(f"Found Incorrect path with function {search_function.__name__}: {path}") + valid = terrain.why_complete_path(path) + if not valid[0]: + raise ValueError(f"Function {search_function.__name__} returned an invalid path: {valid[1]}") cost = terrain.get_path_cost(path) if cost < best_path_cost: diff --git a/src/sIArena/terrain/Terrain.py b/src/sIArena/terrain/Terrain.py index 09b1080..263d7fe 100644 --- a/src/sIArena/terrain/Terrain.py +++ b/src/sIArena/terrain/Terrain.py @@ -1,4 +1,4 @@ -from typing import List, Tuple +from typing import List, Tuple, Set import numpy as np import math @@ -21,26 +21,18 @@ def default_cost_function(origin_height: int, target_height: int) -> int: return (target_height - origin_height) * 2 - -class Terrain: +class NoPathTerrain: """ This class represents a 2D terrain in a NxM int matrix where each value is the height. The problem to solve would be to find the lowest path to go from 0x0 (top left) to N-1xM-1 (bottom right). Each step in the path can only be to the right, down, left or up (no diagonals). - The cost for each step in the path is calculated as follows: - - From cell with cost X to cell with cost Y: - - If X == Y: cost = 1 [keep in the same level] - - If X > Y: cost = X - Y [climb down] - - If X < Y: cost = 2 * (Y - X) [climb up] + The cost for each step in the path is calculated regarding the cost function """ def __init__( self, matrix: List[List[int]], - origin: Coordinate = None, - destination: Coordinate = None, cost_function: callable = default_cost_function, ): """ @@ -48,38 +40,13 @@ def __init__( :param matrix: matrix of integers :param origin: origin of the path (if None top left corner) - :param destination: destination of the path (if None bottom right corner) + :param destinations: list of destinations in order(if None bottom right corner) """ self.matrix = matrix self.n = len(matrix) self.m = len(matrix[0]) self.cost_function = cost_function - self.origin = origin - self.destination = destination - - if self.origin is None: - self.origin = (0, 0) - else: - self.origin = (origin[0], origin[1]) - - if self.destination is None: - self.destination = (self.n - 1, self.m - 1) - else: - self.destination = (destination[0], destination[1]) - - # Check that the origin is valid - if self.origin[0] < 0 and self.origin[0] >= self.n: - raise AttributeError(f"Origin row is out of bounds: {self.origin[0]}") - if self.origin[1] < 0 and self.origin[1] >= self.m: - raise AttributeError(f"Origin column is out of bounds: {self.origin[1]}") - - # Check that the destination is valid - if self.destination[0] < 0 and self.destination[0] >= self.n: - raise AttributeError("Destination row is out of bounds") - if self.destination[1] < 0 and self.destination[1] >= self.m: - raise AttributeError("Destination column is out of bounds") - # Check that the matrix is valid for row in matrix: if len(row) == self.m: @@ -98,12 +65,7 @@ def __str__(self): s = "+" + ("-" * (max_length + 2) + "+") * self.m + "\n" for i in range(self.n): for j in range(self.m): - if (i,j) == self.origin: - s += "|+" - elif (i,j) == self.destination: - s += "|x" - else: - s += "| " + s += "| " s += str(self[(i,j)]).rjust(max_length) + " " s += "|\n" s += "+" + ("-" * (max_length + 2) + "+") * self.m + "\n" @@ -159,16 +121,307 @@ def get_path_cost(self, path: Path) -> int: def is_complete_path(self, path: Path) -> bool: - """Returns True if the given path goes from the top left corner to the bottom right corner""" - return self.is_valid_path(path) and path[0] == self.origin and path[-1] == self.destination + return self.is_valid_path(path) + def why_complete_path(self, path: Path) -> Tuple[bool, str]: + return self.why_valid_path(path) def is_valid_path(self, path: Path) -> bool: + return self.why_valid_path(path)[0] + + def why_valid_path(self, path: Path) -> Tuple[bool, str]: """Returns True if the given path is valid""" if path is None or len(path) == 0: - return False + return False, "Empty path" for i in range(len(path) - 1): if path[i + 1] not in self.get_neighbors(path[i]): - return False - return True + return False, f"Invalid path: {path[i]} -> {path[i + 1]}" + return True, "Valid path" + + + def get_destinations(self) -> List[Coordinate]: + return None + +class Terrain (NoPathTerrain): + """ + This class is a Terrain with an origin and a destination + """ + + def __init__( + self, + matrix: List[List[int]], + origin: Coordinate = None, + destination: Coordinate = None, + cost_function: callable = default_cost_function, + ): + """ + Construct a terrain from a matrix of integers + + :param matrix: matrix of integers + :param origin: origin of the path (if None top left corner) + :param destination: destination of the path (if None bottom right corner) + """ + super().__init__(matrix, cost_function) + self.origin = origin + self.destination = destination + + if self.origin is None: + self.origin = (0, 0) + else: + self.origin = (origin[0], origin[1]) + + if self.destination is None: + self.destination = (self.n - 1, self.m - 1) + else: + self.destination = (destination[0], destination[1]) + + # Check that the origin is valid + if self.origin[0] < 0 and self.origin[0] >= self.n: + raise AttributeError(f"Origin row is out of bounds: {self.origin[0]}") + if self.origin[1] < 0 and self.origin[1] >= self.m: + raise AttributeError(f"Origin column is out of bounds: {self.origin[1]}") + + # Check that the destination is valid + if self.destination[0] < 0 and self.destination[0] >= self.n: + raise AttributeError(f"Destination row is out of bounds: {self.destination[0]}") + if self.destination[1] < 0 and self.destination[1] >= self.m: + raise AttributeError(f"Destination column is out of bounds: {self.destination[1]}") + + + def is_complete_path(self, path: Path) -> bool: + return self.why_complete_path(path)[0] + + def why_complete_path(self, path: Path) -> Tuple[bool, str]: + """Returns True if the given path goes from the origin to the destination""" + # Check that the path is valid + valid = self.why_valid_path(path) + if not valid[0]: + return valid + + # Check that the path goes from the origin to the destination + if path[0] != self.origin: + return False, f"Path does not start in the origin {self.origin}" + if path[-1] != self.destination: + return False, f"Path does not end in the destination {self.destination}" + return True, "Complete path" + + + def __str__(self): + """Returns a string representation of the terrain""" + # Calculate the maximum length of a cell + max_length = len(str(self.matrix.max())) + # Create the string representation + s = "+" + ("-" * (max_length + 3) + "+") * self.m + "\n" + for i in range(self.n): + for j in range(self.m): + s += "|" + if (i,j) == self.origin: + s += "O " + elif (i,j) == self.destination: + s += "X " + else: + s += " " + s += str(self[(i,j)]).rjust(max_length) + " " + s += "|\n" + s += "+" + ("-" * (max_length + 3) + "+") * self.m + "\n" + return s + + + def get_destinations(self) -> List[Coordinate]: + return [self.destination] + + +class MultipleDestinationTerrain (NoPathTerrain): + """ + This class represents a Terrain with an origin and a set of destinations that the paths must go through without order. + """ + + def __init__( + self, + matrix: List[List[int]], + origin: Coordinate = None, + destination: Set[Coordinate] = None, + cost_function: callable = default_cost_function, + ): + """ + Construct a terrain from a matrix of integers + + :param matrix: matrix of integers + :param origin: origin of the path (if None top left corner) + :param destinations: list of destinations in order(if None bottom right corner) + """ + super().__init__(matrix, cost_function) + self.origin = origin + self.destinations = destination + + if self.origin is None: + self.origin = (0, 0) + else: + self.origin = (origin[0], origin[1]) + + if self.destinations is None: + self.destinations = {(self.n - 1, self.m - 1)} + else: + self.destinations = set(destination) + + # Check that the origin is valid + if self.origin[0] < 0 and self.origin[0] >= self.n: + raise AttributeError(f"Origin row is out of bounds: {self.origin[0]}") + if self.origin[1] < 0 and self.origin[1] >= self.m: + raise AttributeError(f"Origin column is out of bounds: {self.origin[1]}") + + # Check that the destinations are valid + for destination in self.destinations: + if destination[0] < 0 and destination[0] >= self.n: + raise AttributeError(f"Destination row is out of bounds: {destination[0]}") + if destination[1] < 0 and destination[1] >= self.m: + raise AttributeError(f"Destination column is out of bounds: {destination[1]}") + # Check there are not the origin + if destination == self.origin: + raise AttributeError(f"Destination is the origin: {destination}") + + + def is_complete_path(self, path: Path) -> bool: + return self.why_complete_path(path)[0] + + def why_complete_path(self, path: Path) -> Tuple[bool, str]: + """Returns True if the given path goes from the origin to all the destinations""" + # Check that the path is valid + valid = self.why_valid_path(path) + if not valid[0]: + return valid + + # Check that the path goes from the origin to all the destinations + if path[0] != self.origin: + return False, f"Path does not start in the origin {self.origin}" + + for destination in self.destinations: + if destination not in path: + return False, f"Path does not go through the destination {destination}" + + return True, "Complete path" + + + def __str__(self): + """Returns a string representation of the terrain""" + # Calculate the maximum length of a cell + max_length = len(str(self.matrix.max())) + # Create the string representation + s = "+" + ("-" * (max_length + 3) + "+") * self.m + "\n" + for i in range(self.n): + for j in range(self.m): + s += "|" + if (i,j) == self.origin: + s += "O " + elif (i,j) in self.destinations: + s += "X " + else: + s += " " + s += str(self[(i,j)]).rjust(max_length) + " " + s += "|\n" + s += "+" + ("-" * (max_length + 3) + "+") * self.m + "\n" + return s + + + def get_destinations(self) -> Set[Coordinate]: + return self.destinations + + + +class SequentialDestinationTerrain (NoPathTerrain): + """ + This class represents a Terrain with an origin and a list of destinations that the paths must go through in order. + """ + + def __init__( + self, + matrix: List[List[int]], + origin: Coordinate = None, + destination: List[Coordinate] = None, + cost_function: callable = default_cost_function, + ): + """ + Construct a terrain from a matrix of integers + + :param matrix: matrix of integers + :param origin: origin of the path (if None top left corner) + :param destinations: list of destinations in order(if None bottom right corner) + """ + super().__init__(matrix, cost_function) + self.origin = origin + self.destinations = destination + + if self.origin is None: + self.origin = (0, 0) + else: + self.origin = (origin[0], origin[1]) + + if self.destinations is None: + self.destinations = [(self.n - 1, self.m - 1)] + else: + self.destinations = destination + + # Check that the origin is valid + if self.origin[0] < 0 and self.origin[0] >= self.n: + raise AttributeError(f"Origin row is out of bounds: {self.origin[0]}") + if self.origin[1] < 0 and self.origin[1] >= self.m: + raise AttributeError(f"Origin column is out of bounds: {self.origin[1]}") + + # Check that the destinations are valid + for destination in self.destinations: + if destination[0] < 0 and destination[0] >= self.n: + raise AttributeError(f"Destination row is out of bounds: {destination[0]}") + if destination[1] < 0 and destination[1] >= self.m: + raise AttributeError(f"Destination column is out of bounds: {destination[1]}") + + + def is_complete_path(self, path: Path) -> bool: + return self.why_complete_path(path)[0] + + def why_complete_path(self, path: Path) -> Tuple[bool, str]: + """Returns True if the given path goes from the origin to all the destinations in order""" + # Check that the path is valid + valid = self.why_valid_path(path) + if not valid[0]: + return valid + + # Check that the path goes from the origin to all the destinations in order + if path[0] != self.origin: + return False, f"Path does not start in the origin {self.origin}" + + path_index = 1 + for i in range(len(self.destinations)): + while path[path_index] != self.destinations[i]: + path_index += 1 + if path_index >= len(path): + return False, f"Path does not go through the destination {self.destinations[i]}" + + return True, "Complete path" + + + def __str__(self): + """Returns a string representation of the terrain""" + # Calculate the maximum length of a cell + max_length = len(str(self.matrix.max())) + # Create the string representation + s = "+" + ("-" * (max_length + 5) + "+") * self.m + "\n" + k = 1 + for i in range(self.n): + for j in range(self.m): + s += "|" + if (i,j) == self.origin: + s += "<0> " + elif (i,j) in self.destinations: + s += f"<{k}> " + k += 1 + else: + s += " " + s += str(self[(i,j)]).rjust(max_length) + " " + s += "|\n" + s += "+" + ("-" * (max_length + 5) + "+") * self.m + "\n" + return s + + + def get_destinations(self) -> List[Coordinate]: + return self.destinations diff --git a/src/sIArena/terrain/generator/Generator.py b/src/sIArena/terrain/generator/Generator.py index 2d2e3fe..a8ca6a3 100644 --- a/src/sIArena/terrain/generator/Generator.py +++ b/src/sIArena/terrain/generator/Generator.py @@ -19,7 +19,9 @@ def generate_random_terrain( abruptness: float = 0.2, seed: int = None, origin: Coordinate = None, - destination: Coordinate = None + destination: Coordinate = None, + terrain_ctor: Terrain = Terrain, + cost_function: callable = None ) -> Terrain: # Max and min abruptness abruptness = min(1, max(0, abruptness)) @@ -45,7 +47,10 @@ def generate_random_terrain( final_m *= min_step - return Terrain(final_m, origin=origin, destination=destination) + if cost_function is not None: + return terrain_ctor(final_m, origin=origin, destination=destination, cost_function=cost_function) + else: + return terrain_ctor(final_m, origin=origin, destination=destination) @pure_virtual diff --git a/src/sIArena/terrain/generator/MazeGenerator.py b/src/sIArena/terrain/generator/MazeGenerator.py new file mode 100644 index 0000000..961690d --- /dev/null +++ b/src/sIArena/terrain/generator/MazeGenerator.py @@ -0,0 +1,248 @@ +import random +import math +from typing import List +import numpy as np + +from sIArena.terrain.Terrain import Coordinate, Terrain +from sIArena.terrain.generator.Generator import TerrainGenerator + +from sIArena.utils.decorators import override + + +class MazeGenerator(TerrainGenerator): + + def generate_random_terrain( + self, + n: int, + m: int, + min_height: int = 0, + max_height: int = 99, + min_step: int = 1, + abruptness: float = 0.2, + seed: int = None, + origin: Coordinate = None, + destination: Coordinate = None, + terrain_ctor: Terrain = Terrain, + cost_function: callable = None + ) -> Terrain: + """ This inherited method set the min, max and step height of the terrain""" + if min_step == 1: + min_height = 0 + max_height = n*m + min_step = n*m + + return super().generate_random_terrain( + n=n, m=m, min_height=min_height, max_height=max_height, min_step=min_step, abruptness=abruptness, seed=seed, origin=origin, destination=destination, terrain_ctor=terrain_ctor, cost_function=cost_function) + + + @override + def generate_random_matrix_( + self, + n: int, + m: int, + abruptness: float = 0.5, + seed: int = None + ) -> np.matrix: + """ + TODO + """ + + if seed is not None: + random.seed(seed) + + maze_n = (1 + n) // 2 + maze_m = (1 + m) // 2 + + maze = Maze(maze_n, maze_m) + + matrix = np.zeros((n, m)) + + for i in range(maze_n): + for j in range(maze_m): + up_left = (2*i, 2*j) # always 0 + down_right = (2*i+1, 2*j+1) # always 1 + up_right = (2*i, 2*j+1) # 1 when no right wall + down_left = (2*i+1, 2*j) # 1 when no down wall + + if 2*j+1 < m and not maze.tile((i, j)).right: + matrix[up_right] = 1 + if 2*i+1 < n and not maze.tile((i, j)).down: + matrix[down_left] = 1 + if 2*i+1 < n and 2*j+1 < m: + matrix[down_right] = 1 + + # Assure last cell is 0 + matrix[-1][-1] = 0 + + # If both n and m are even, add a connection between the last two cells + if n % 2 == 0 and m % 2 == 0: + matrix[-2][-1] = 0 + + return matrix + + +class Tile: + + def __init__(self): + self.up = False + self.down = False + self.left = False + self.right = False + self.visited = False + + def add_up(self): + self.up = True + + def add_down(self): + self.down = True + + def add_left(self): + self.left = True + + def add_right(self): + self.right = True + + def visit(self): + self.visited = True + + +def weigthed_distrubution(n, r=10): + + if n == 1: + return 0 + + choices = [i for i in range(n)] + weights = [1 for i in range(n)] + + x = 0 + for i in range(n): + weights[i] *= n-1 + weights[i] += x + x += r - 1 + + return random.choices(choices, weights=weights, k=1)[0] + + +class Maze: + + def __init__(self, n, m, start_point=None): + self.n = n + self.m = m + self.matrix = [[Tile() for _ in range(m)] for _ in range(n)] + + if start_point is None: + current = (n//2, m//2) + else: + current = start_point + + self.matrix[current[0]][current[1]].visit() + to_visit = self.surrounding_coordinates(current) + + + while to_visit: + + # Get a random number from 0 to n being the last ones more probable + n = len(to_visit) + r = weigthed_distrubution(n) + next_tile = to_visit.pop(r) + + if self.tile(next_tile).visited: + continue + + self.matrix[next_tile[0]][next_tile[1]].visit() + + # Get all surrounding tiles + surrounding = self.surrounding_coordinates(next_tile) + + # Separate visited from non visited + visited = [] + non_visited = [] + for s in surrounding: + if self.tile(s).visited: + visited.append(s) + else: + non_visited.append(s) + + # Join with a random visited one + if visited: + r = random.randint(0, len(visited) - 1) + visited_tile = visited[r] + self.join_tiles(next_tile, visited_tile) + self.tile(visited_tile).visit() + else: + raise Exception("No visited tiles around") + + # Add non visited to to_visit + for s in non_visited: + to_visit.append(s) + + + def tile(self, coor): + return self.matrix[coor[0]][coor[1]] + + def surrounding_coordinates(self, coor): + n, m = coor + coords = [] + if n > 0: + coords.append((n - 1, m)) + if n < self.n - 1: + coords.append((n + 1, m)) + if m > 0: + coords.append((n, m - 1)) + if m < self.m - 1: + coords.append((n, m + 1)) + return coords + + def join_tiles(self, tile1, tile2): + # Check if tile2 is up, down, left or right of tile1 + n1, m1 = tile1 + n2, m2 = tile2 + + if n1 == n2: + if m1 < m2: + self.matrix[n1][m1].add_right() + self.matrix[n2][m2].add_left() + else: + self.matrix[n1][m1].add_left() + self.matrix[n2][m2].add_right() + + elif m1 == m2: + if n1 < n2: + self.matrix[n1][m1].add_down() + self.matrix[n2][m2].add_up() + else: + self.matrix[n1][m1].add_up() + self.matrix[n2][m2].add_down() + + else: + raise Exception("Tiles are not adjacent") + + def __str__(self): + s = "" + for i in range(self.n): + for j in range(self.m): + if self.matrix[i][j].up: + s += "+ +" + else: + s += "+----+" + s += "\n" + for _ in range(2): + for j in range(self.m): + if self.matrix[i][j].left: + s += " " + else: + s += "|" + s += " " + if self.matrix[i][j].right: + s += " " + else: + s += "|" + s += "\n" + for j in range(self.m): + if self.matrix[i][j].down: + s += "+ +" + else: + s += "+----+" + s += "\n" + + return s diff --git a/src/sIArena/terrain/generator/PernilGenerator.py b/src/sIArena/terrain/generator/PerlinGenerator.py similarity index 92% rename from src/sIArena/terrain/generator/PernilGenerator.py rename to src/sIArena/terrain/generator/PerlinGenerator.py index b0cf99f..0e9d9b4 100644 --- a/src/sIArena/terrain/generator/PernilGenerator.py +++ b/src/sIArena/terrain/generator/PerlinGenerator.py @@ -8,7 +8,7 @@ from sIArena.utils.decorators import override -class PernilGenerator(TerrainGenerator): +class PerlinGenerator(TerrainGenerator): @override def generate_random_matrix_( @@ -33,12 +33,12 @@ def generate_random_matrix_( map = np.zeros((n,m)) for i in range(n): for j in range(m): - map[i][j] = PernilGenerator.pernil_value_generator( + map[i][j] = PerlinGenerator.perlin_value_generator( i, j, base, scale) return map - def pernil_value_generator( + def perlin_value_generator( i: int, j: int, base: int, @@ -48,7 +48,7 @@ def pernil_value_generator( lacunarity: float = 2.0 ) -> int: """ - Generates a random value for a cell of a terrain using Pernil noise + Generates a random value for a cell of a terrain using Perlin noise :param i: row of the cell :param j: column of the cell diff --git a/src/sIArena/terrain/plot/plot_2D.py b/src/sIArena/terrain/plot/plot_2D.py index 1dc314d..d783b9b 100644 --- a/src/sIArena/terrain/plot/plot_2D.py +++ b/src/sIArena/terrain/plot/plot_2D.py @@ -24,7 +24,8 @@ def plot_terrain_2D( # Mark with red the origin and destination plt.plot(terrain.origin[1], terrain.origin[0], 'r+') - plt.plot(terrain.destination[1], terrain.destination[0], 'rx') + for dest in terrain.get_destinations(): + plt.plot(dest[1], dest[0], 'rx') # Set path legends if unset paths_legends_ = paths_legends