diff --git a/evennia/contrib/mapsystem/__init__.py b/evennia/contrib/mapsystem/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/evennia/contrib/map_and_pathfind/example_map.py b/evennia/contrib/mapsystem/map_example.py similarity index 100% rename from evennia/contrib/map_and_pathfind/example_map.py rename to evennia/contrib/mapsystem/map_example.py diff --git a/evennia/contrib/map_and_pathfind/mapsystem.py b/evennia/contrib/mapsystem/map_legend.py similarity index 53% rename from evennia/contrib/map_and_pathfind/mapsystem.py rename to evennia/contrib/mapsystem/map_legend.py index e023e1000a..95a1a1d88f 100644 --- a/evennia/contrib/map_and_pathfind/mapsystem.py +++ b/evennia/contrib/mapsystem/map_legend.py @@ -1,146 +1,21 @@ -r""" -# Map system - -Evennia - Griatch 2021 - -Implement mapping, with path searching. - -This builds a map graph based on an ASCII map-string with special, user-defined symbols. -Each room (MapNode) can have exits (links) in 8 cardinal directions (north, northwest etc) as well -as up and down. These are indicated in code as 'n', 'ne', 'e', 'se', 's', 'sw', 'w', -'nw', 'u' and 'd'. - - -```python - # in module passed to 'Map' class. It will either a dict - # MAP_DATA with keys 'map' and (optionally) 'legend', or - # the MAP/LEGEND variables directly. - - MAP = r''' - 1 - + 0 1 2 3 4 5 6 7 8 9 0 - - 10 # # # # # - \ I I I d - 9 #-#-#-# | - |\ | u - 8 #-#-#-#-----#-----o - | | | - 7 #-#---#-#-#-#-# | - | |x|x| | - 6 o-#-#-# #-#-#-#-# - \ |x|x| - 5 o---#-#<--#-#-# - / | - 4 #-----+-# #---# - \ | | \ / - 3 #b#-#-# x # - | | / \ u - 2 #-#-#---# - ^ d - 1 #-# # - | - 0 #-#---o - - + 0 1 2 3 4 5 6 7 8 9 1 - 0 - - ''' - - LEGEND = {'#': mapsystem.MapNode, '|': mapsystem.NSMapLink,...} - - # optional, for more control - MAP_DATA = { - "map": MAP, - "legend": LEGEND, - } - -``` - -The two `+` signs in the upper/lower left corners are required and marks the edge of the map area. -The origo of the grid is always two steps right and two up from the bottom test marker and the grid -extends to two lines below the top-left marker. Anything outside the grid is ignored, so numbering -the coordinate axes is optional but recommended for readability. - -The XY positions represent XY positions in the game world. When existing, they are usually -represented by Rooms in-game. The links between nodes would normally represent Exits, but the length -of links on the map have no in-game equivalence except that traversing a multi-step link will place -you in a location with an XY coordinate different from what you'd expect by a single step (most -games don't relay the XY position to the player anyway). - -In the map string, every XY coordinate must have exactly one spare space/line between them - this is -used for node linkings. This finer grid which has 2x resolution of the `XYgrid` is only used by the -mapper and is referred to as the `xygrid` (small xy) internally. Note that an XY position can also -be held by a link (for example a passthrough). - -The nodes and links can be customized by add your own implementation of `MapNode` or `MapLink` to -the LEGEND dict, mapping them to a particular character symbol. A `MapNode` can only be added -on an even XY coordinate while `MapLink`s can be added anywhere on the xygrid. - -See `./example_maps.py` for some empty grid areas to start from. - ----- """ -import pickle -from collections import defaultdict -from os import mkdir -from os.path import isdir, isfile, join as pathjoin +# Map legend components + +Each map-legend component is either a 'mapnode' - something that represents and actual in-game +location (usually a room) or a 'maplink' - something connecting nodes together. The start of a link +usually shows as an Exit, but the length of the link has no in-game equivalent. + +""" try: - from scipy.sparse.csgraph import dijkstra - from scipy.sparse import csr_matrix from scipy import zeros except ImportError as err: raise ImportError( f"{err}\nThe MapSystem contrib requires " "the SciPy package. Install with `pip install scipy'.") -from django.conf import settings -from evennia.utils.utils import variable_from_module, mod_import -from evennia.utils import logger -_CACHE_DIR = settings.CACHE_DIR +from .utils import MAPSCAN, REVERSE_DIRECTIONS, MapParserError, BIGVAL -_BIG = 999999999999 - - -_REVERSE_DIRECTIONS = { - "n": "s", - "ne": "sw", - "e": "w", - "se": "nw", - "s": "n", - "sw": "ne", - "w": "e", - "nw": "se", -} - -_MAPSCAN = { - "n": (0, 1), - "ne": (1, 1), - "e": (1, 0), - "se": (1, -1), - "s": (0, -1), - "sw": (-1, -1), - "w": (-1, 0), - "nw": (-1, 1), -} - -# errors for Map system - -class MapError(RuntimeError): - - def __init__(self, error="", node_or_link=None): - prefix = "" - if node_or_link: - prefix = (f"{node_or_link.__class__.__name__} '{node_or_link.symbol}' " - f"at XY=({node_or_link.X:g},{node_or_link.Y:g}) ") - self.node_or_link = node_or_link - self.message = f"{prefix}{error}" - super().__init__(self.message) - - -class MapParserError(MapError): - pass # Nodes/Links @@ -247,7 +122,7 @@ class MapNode: x, y = self.x, self.y # scan in all directions for links - for direction, (dx, dy) in _MAPSCAN.items(): + for direction, (dx, dy) in MAPSCAN.items(): lx, ly = x + dx, y + dy @@ -256,7 +131,7 @@ class MapNode: # just because there is a link here, doesn't mean it has a # connection in this direction. If so, the `end_node` will be None. - end_node, weight, steps = link.traverse(_REVERSE_DIRECTIONS[direction], xygrid) + end_node, weight, steps = link.traverse(REVERSE_DIRECTIONS[direction], xygrid) if end_node: # the link could be followed to an end node! @@ -282,7 +157,8 @@ class MapNode: # used for building the shortest path. Note that we store the # aliased link directions here, for quick display by the # shortest-route solver - shortest_route = self.shortest_route_to_node.get(node_index, ("", [], _BIG))[2] + shortest_route = self.shortest_route_to_node.get( + node_index, ("", [], BIGVAL))[2] if weight < shortest_route: self.shortest_route_to_node[node_index] = (first_step_name, steps, weight) @@ -327,7 +203,7 @@ class MapNode: class MapLink: """ - This represents one or more links between an 'incoming diretion' + This represents one or more links between an 'incoming direction' and an 'outgoing direction'. It's like a railway track between MapNodes. A Link can be placed on any location in the grid, but even when on an integer XY position they still don't represent an actual in-game place @@ -399,7 +275,7 @@ class MapLink: # this is required for pathfinding and contains cardinal directions (n, ne etc) only. # Each weight is defined as {startpos:weight}, where # the startpos is the direction of the cell (n,ne etc) where the link *starts*. The - # weight is a value > 0, smaller than _BIG. The get_weight method can be + # weight is a value > 0, smaller than BIGVAL. The get_weight method can be # customized to modify to return something else. weights = {} # this shortcuts neighbors trying to figure out if they can connect to this link @@ -463,7 +339,7 @@ class MapLink: # note that if `get_direction` returns an unknown direction, this will be equivalent # to pointing to an empty location, which makes sense - dx, dy = _MAPSCAN.get(end_direction, (_BIG, _BIG)) + dx, dy = MAPSCAN.get(end_direction, (BIGVAL, BIGVAL)) end_x, end_y = self.x + dx, self.y + dy try: next_target = xygrid[end_x][end_y] @@ -491,7 +367,7 @@ class MapLink: else: # we hit another link. Progress recursively. return next_target.traverse( - _REVERSE_DIRECTIONS.get(end_direction, end_direction), + REVERSE_DIRECTIONS.get(end_direction, end_direction), xygrid, _weight=_weight, _linklen=_linklen + 1, _steps=_steps) def get_linked_neighbors(self, xygrid, directions=None): @@ -508,11 +384,11 @@ class MapLink: """ if not directions: - directions = _REVERSE_DIRECTIONS.keys() + directions = REVERSE_DIRECTIONS.keys() links = {} for direction in directions: - dx, dy = _MAPSCAN[direction] + dx, dy = MAPSCAN[direction] end_x, end_y = self.x + dx, self.y + dy if end_x in xygrid and end_y in xygrid[end_x]: # there is is something there, we need to check if it is either @@ -649,8 +525,8 @@ class SmartRerouterMapLink(MapLink): # the dynamic link and remove them from the unhandled_links list unhandled_links_copy = unhandled_links.copy() for direction in unhandled_links_copy: - if _REVERSE_DIRECTIONS[direction] in unhandled_links_copy: - directions[direction] = _REVERSE_DIRECTIONS[ + if REVERSE_DIRECTIONS[direction] in unhandled_links_copy: + directions[direction] = REVERSE_DIRECTIONS[ unhandled_links.pop(unhandled_links.index(direction))] # check if we have any non-cross-through paths left to handle @@ -897,10 +773,10 @@ class SmartMapLink(MapLink): if len(nodes) == 2: # prefer link to these two nodes for direction in nodes: - directions[direction] = _REVERSE_DIRECTIONS[direction] + directions[direction] = REVERSE_DIRECTIONS[direction] elif len(neighbors) - len(nodes) == 1: for direction in neighbors: - directions[direction] = _REVERSE_DIRECTIONS[direction] + directions[direction] = REVERSE_DIRECTIONS[direction] else: raise MapParserError( f"must have exactly two connections - either " @@ -989,12 +865,14 @@ class BasicMapNode(MapNode): """Basic map Node""" symbol = "#" + class InterruptMapNode(MapNode): """A point of interest, where pathfinder will stop""" - symbol = "i" + symbol = "I" display_symbol = "#" interrupt_path = True + class NSMapLink(MapLink): """Two-way, North-South link""" symbol = "|" @@ -1012,6 +890,7 @@ class NESWMapLink(MapLink): symbol = "/" directions = {"ne": "sw", "sw": "ne"} + class SENWMapLink(MapLink): """Two-way, SouthEast-NorthWest link""" symbol = "\\" @@ -1024,12 +903,14 @@ class PlusMapLink(MapLink): directions = {"s": "n", "n": "s", "e": "w", "w": "e"} + class CrossMapLink(MapLink): """Two-way, crossing NorthEast-SouthWest and SouthEast-NorthWest links""" symbol = "x" directions = {"ne": "sw", "sw": "ne", "se": "nw", "nw": "se"} + class NSOneWayMapLink(MapLink): """One-way North-South link""" symbol = "v" @@ -1062,6 +943,7 @@ class UpMapLink(SmartMapLink): direction_aliases = {'n': symbol, 'ne': symbol, 'e': symbol, 'se': symbol, 's': symbol, 'sw': symbol, 'w': symbol, 'nw': symbol} + class DownMapLink(UpMapLink): """Works exactly like `UpMapLink` but for the 'down' direction.""" symbol = 'd' @@ -1075,6 +957,7 @@ class InterruptMapLink(InvisibleSmartMapLink): symbol = "i" interrupt_path = True + class BlockedMapLink(InvisibleSmartMapLink): """ A high-weight (but still passable) link that causes the shortest-path algorithm to consider this @@ -1083,661 +966,10 @@ class BlockedMapLink(InvisibleSmartMapLink): """ symbol = 'b' - weights = {'n': _BIG, 'ne': _BIG, 'e': _BIG, 'se': _BIG, - 's': _BIG, 'sw': _BIG, 'w': _BIG, 'nw': _BIG} + weights = {'n': BIGVAL, 'ne': BIGVAL, 'e': BIGVAL, 'se': BIGVAL, + 's': BIGVAL, 'sw': BIGVAL, 'w': BIGVAL, 'nw': BIGVAL} class RouterMapLink(SmartRerouterMapLink): """Connects multiple links to build knees, pass-throughs etc.""" symbol = "o" - - -# these are all symbols used for x,y coordinate spots -# at (0,1) etc. -DEFAULT_LEGEND = { - "#": BasicMapNode, - "I": InterruptMapNode, - "|": NSMapLink, - "-": EWMapLink, - "/": NESWMapLink, - "\\": SENWMapLink, - "x": CrossMapLink, - "+": PlusMapLink, - "v": NSOneWayMapLink, - "^": SNOneWayMapLink, - "<": EWOneWayMapLink, - ">": WEOneWayMapLink, - "o": RouterMapLink, - "u": UpMapLink, - "d": DownMapLink, - "b": BlockedMapLink, - "i": InterruptMapLink, - 't': TeleporterMapLink, - 'T': MapTransitionLink, -} - -# -------------------------------------------- -# Map parser implementation - - -class Map: - r""" - This represents a map of interconnected nodes/rooms. Each room is connected to each other as a - directed graph with optional 'weights' between the the connections. It is created from a map - string with symbols describing the topological layout. It also provides pathfinding using the - Dijkstra algorithm. - - The map-string is read from a string or from a module. The grid area of the string is marked by - two `+` characters - one in the top left of the area and the other in the bottom left. - The grid starts two spaces/lines in from the 'open box' created by these two markers and extend - any width to the right. - Any other markers or comments can be added outside of the grid - they will be ignored. Every - grid coordinate must always be separated by exactly one space/line since the space between - are used for links. - :: - ''' - 1 1 1 - + 0 1 2 3 4 5 6 7 8 9 0 1 2 ... - - 4 # # # - | \ / - 3 #-#-# # # - | \ / - 2 #-#-# # - |x|x| | - 1 #-#-#-#-#-#-# - / - 0 #-# - - + 0 1 2 3 4 5 6 7 8 9 1 1 1 ... - 0 1 2 - ''' - - So origo (0,0) is in the bottom-left and north is +y movement, south is -y movement - while east/west is +/- x movement as expected. Adding numbers to axes is optional - but recommended for readability! - - """ - mapcorner_symbol = '+' - max_pathfinding_length = 500 - empty_symbol = ' ' - # we normally only accept one single character for the legend key - legend_key_exceptions = ("\\") - - def __init__(self, map_module_or_dict, name="map"): - """ - Initialize the map parser by feeding it the map. - - Args: - map_module_or_dict (str, module or dict): Path or module pointing to a map. If a dict, - this should be a dict with a key 'map' and optionally a 'legend' - dicts to specify the map structure. - name (str, optional): Unique identifier for this map. Needed if the game uses - more than one map. Used when referencing this map during map transitions, - baking of pathfinding matrices etc. - - Notes: - The map deals with two sets of coorinate systems: - - grid-coordinates x,y are the character positions in the map string. - - world-coordinates X,Y are the in-world coordinates of nodes/rooms. - There are fewer of these since they ignore the 'link' spaces between - the nodes in the grid, so - - X = x // 2 - Y = y // 2 - - """ - self.name = name - - mapstring = "" - - # store so we can reload - self.map_module_or_dict = map_module_or_dict - - # map setup - self.xygrid = None - self.XYgrid = None - self.display_map = None - self.max_x = 0 - self.max_y = 0 - self.max_X = 0 - self.max_Y = 0 - - # Dijkstra algorithm variables - self.node_index_map = None - self.dist_matrix = None - self.pathfinding_routes = None - - self.pathfinder_baked_filename = None - if name: - if not isdir(_CACHE_DIR): - mkdir(_CACHE_DIR) - self.pathfinder_baked_filename = pathjoin(_CACHE_DIR, f"{name}.P") - - # load data and parse it - self.reload() - - def __str__(self): - """ - Print the string representation of the map. - Since the y-axes origo is at the bottom, we must flip the - y-axis before printing (since printing is always top-to-bottom). - - """ - return "\n".join("".join(line) for line in self.display_map[::-1]) - - def __repr__(self): - return f"" - - def _get_topology_around_coord(self, coord, dist=2): - """ - Get all links and nodes up to a certain distance from an XY coordinate. - - Args: - coord (tuple), the X,Y coordinate of the center point. - dist (int): How many nodes away from center point to find paths for. - - Returns: - tuple: A tuple of 5 elements `(coords, xmin, xmax, ymin, ymax)`, where the - first element is a list of xy-coordinates (on xygrid) for all linked nodes within - range. This is meant to be used with the xygrid for extracting a subset - for display purposes. The others are the minimum size of the rectangle - surrounding the area containing `coords`. - - Notes: - This performs a depth-first pass down the the given dist. - - """ - def _scan_neighbors(start_node, points, dist=2, - xmin=_BIG, ymin=_BIG, xmax=0, ymax=0, depth=0): - - x0, y0 = start_node.x, start_node.y - points.append((x0, y0)) - xmin, xmax = min(xmin, x0), max(xmax, x0) - ymin, ymax = min(ymin, y0), max(ymax, y0) - - if depth < dist: - # keep stepping - for direction, end_node in start_node.links.items(): - x, y = x0, y0 - for link in start_node.xy_steps_to_node[direction]: - x, y = link.x, link.y - points.append((x, y)) - xmin, xmax = min(xmin, x), max(xmax, x) - ymin, ymax = min(ymin, y), max(ymax, y) - - points, xmin, xmax, ymin, ymax = _scan_neighbors( - end_node, points, dist=dist, - xmin=xmin, ymin=ymin, xmax=xmax, ymax=ymax, - depth=depth + 1) - - return points, xmin, xmax, ymin, ymax - - center_node = self.get_node_from_coord(coord) - points, xmin, xmax, ymin, ymax = _scan_neighbors(center_node, [], dist=dist) - return list(set(points)), xmin, xmax, ymin, ymax - - def _calculate_path_matrix(self): - """ - Solve the pathfinding problem using Dijkstra's algorithm. This will try to - load the solution from disk if possible. - - """ - if self.pathfinder_baked_filename and isfile(self.pathfinder_baked_filename): - # check if the solution for this grid was already solved previously. - - mapstr, dist_matrix, pathfinding_routes = "", None, None - with open(self.pathfinder_baked_filename, 'rb') as fil: - try: - mapstr, dist_matrix, pathfinding_routes = pickle.load(fil) - except Exception: - logger.log_trace() - if (mapstr == self.mapstring - and dist_matrix is not None - and pathfinding_routes is not None): - # this is important - it means the map hasn't changed so - # we can re-use the stored data! - self.dist_matrix = dist_matrix - self.pathfinding_routes = pathfinding_routes - return - - # build a matrix representing the map graph, with 0s as impassable areas - - nnodes = len(self.node_index_map) - pathfinding_graph = zeros((nnodes, nnodes)) - for inode, node in self.node_index_map.items(): - pathfinding_graph[inode, :] = node.linkweights(nnodes) - - # create a sparse matrix to represent link relationships from each node - pathfinding_matrix = csr_matrix(pathfinding_graph) - - # solve using Dijkstra's algorithm - self.dist_matrix, self.pathfinding_routes = dijkstra( - pathfinding_matrix, directed=True, - return_predecessors=True, limit=self.max_pathfinding_length) - - if self.pathfinder_baked_filename: - # try to cache the results - with open(self.pathfinder_baked_filename, 'wb') as fil: - pickle.dump((self.mapstring, self.dist_matrix, self.pathfinding_routes), - fil, protocol=4) - - def _parse(self): - """ - Parses the numerical grid from the string. The result of this is a 2D array - of [[MapNode,...], [MapNode, ...]] with MapLinks inside them describing their - linkage to other nodes. See the class docstring for details of how the grid - should be defined. - - Notes: - In this parsing, the 'xygrid' is the full range of chraracters read from - the string. The `XYgrid` is used to denote the game-world coordinates - (which doesn't include the links) - - """ - mapcorner_symbol = self.mapcorner_symbol - # this allows for string-based [x][y] mapping with arbitrary objects - xygrid = defaultdict(dict) - # mapping nodes to real X,Y positions - XYgrid = defaultdict(dict) - # needed by pathfinder - node_index_map = {} - - mapstring = self.mapstring - if mapstring.count(mapcorner_symbol) < 2: - raise MapParserError( - f"The mapstring must have at least two '{mapcorner_symbol}' " - "symbols marking the upper- and bottom-left corners of the " - "grid area.") - - # find the the position (in the string as a whole) of the top-left corner-marker - maplines = mapstring.split("\n") - topleft_marker_x, topleft_marker_y = -1, -1 - for topleft_marker_y, line in enumerate(maplines): - topleft_marker_x = line.find(mapcorner_symbol) - if topleft_marker_x != -1: - break - if -1 in (topleft_marker_x, topleft_marker_y): - raise MapParserError(f"No top-left corner-marker ({mapcorner_symbol}) found!") - - # find the position (in the string as a whole) of the bottom-left corner-marker - # this is always in a stright line down from the first marker - botleft_marker_x, botleft_marker_y = topleft_marker_x, -1 - for botleft_marker_y, line in enumerate(maplines[topleft_marker_y + 1:]): - if line.find(mapcorner_symbol) == topleft_marker_x: - break - if botleft_marker_y == -1: - raise MapParserError(f"No bottom-left corner-marker ({mapcorner_symbol}) found! " - "Make sure it lines up with the top-left corner-marker " - f"(found at column {topleft_marker_x} of the string).") - # the actual coordinate is dy below the topleft marker so we need to shift - botleft_marker_y += topleft_marker_y + 1 - - # in-string_position of the top- and bottom-left grid corners (2 steps in from marker) - # the bottom-left corner is also the origo (0,0) of the grid. - topleft_y = topleft_marker_y + 2 - origo_x, origo_y = botleft_marker_x + 2, botleft_marker_y - 1 - - # highest actually filled grid points - max_x = 0 - max_y = 0 - max_X = 0 - max_Y = 0 - node_index = -1 - - # first pass: read string-grid (left-right, bottom-up) and parse all grid points - for iy, line in enumerate(reversed(maplines[topleft_y:origo_y])): - even_iy = iy % 2 == 0 - for ix, char in enumerate(line[origo_x:]): - # from now on, coordinates are on the xygrid. - - if char == self.empty_symbol: - continue - - # only set this if there's actually something on the line - max_x, max_y = max(max_x, ix), max(max_y, iy) - - mapnode_or_link_class = self.legend.get(char) - if not mapnode_or_link_class: - raise MapParserError( - f"Symbol '{char}' on XY=({ix / 2:g},{iy / 2:g}) " - "is not found in LEGEND." - ) - if hasattr(mapnode_or_link_class, "node_index"): - # A mapnode. Mapnodes can only be placed on even grid positions, where - # there are integer X,Y coordinates defined. - - if not (even_iy and ix % 2 == 0): - raise MapParserError( - f"Symbol '{char}' on XY=({ix / 2:g},{iy / 2:g}) marks a " - "MapNode but is located between integer (X,Y) positions (only " - "Links can be placed between coordinates)!") - - # save the node to several different maps for different uses - # in both coordinate systems - iX, iY = ix // 2, iy // 2 - max_X, max_Y = max(max_X, iX), max(max_Y, iY) - node_index += 1 - - xygrid[ix][iy] = XYgrid[iX][iY] = node_index_map[node_index] = \ - mapnode_or_link_class(node_index=node_index, x=ix, y=iy) - - else: - # we have a link at this xygrid position (this is ok everywhere) - xygrid[ix][iy] = mapnode_or_link_class(ix, iy) - - # second pass: Here we loop over all nodes and have them connect to each other - # via the detected linkages. - for node in node_index_map.values(): - node.scan_all_directions(xygrid) - - # build display map - display_map = [[" "] * (max_x + 1) for _ in range(max_y + 1)] - for ix, ydct in xygrid.items(): - for iy, node_or_link in ydct.items(): - display_map[iy][ix] = node_or_link.get_display_symbol(xygrid, mapinstance=self) - - # store - self.max_x, self.max_y = max_x, max_y - self.xygrid = xygrid - - self.max_X, self.max_Y = max_X, max_Y - self.XYgrid = XYgrid - - self.node_index_map = node_index_map - self.display_map = display_map - - def reload(self, map_module_or_dict=None): - """ - (Re)Load a map. - - Args: - map_module_or_dict (str, module or dict, optional): See description for the variable - in the class' `__init__` function. If given, replace the already loaded - map with a new one. If not given, the existing one given on class creation - will be reloaded. - parse (bool, optional): If set, auto-run `.parse()` on the newly loaded data. - - Notes: - This will both (re)load the data and parse it into a new map structure, replacing any - existing one. - - """ - if not map_module_or_dict: - map_module_or_dict = self.map_module_or_dict - - mapdata = {} - if isinstance(map_module_or_dict, dict): - mapdata = map_module_or_dict - else: - mod = mod_import(map_module_or_dict) - mapdata = variable_from_module(mod, "MAP_DATA") - if not mapdata: - mapdata['map'] = variable_from_module(mod, "MAP") - mapdata['legend'] = variable_from_module(mod, "LEGEND", default=DEFAULT_LEGEND) - - # validate - for key in mapdata.get('legend', DEFAULT_LEGEND): - if not key or len(key) > 1: - if key not in self.legend_key_exceptions: - raise MapError(f"Map-legend key '{key}' is invalid: All keys must " - "be exactly one character long. Use the node/link's " - "`.display_symbol` property to change how it is " - "displayed.") - if 'map' not in mapdata or not mapdata['map']: - raise MapError("No map found. Add 'map' key to map-data (MAP_DATA) dict or " - "add variable MAP to a module passed into the parser.") - - # store/update result - self.mapstring = mapdata['map'] - self.legend = map_module_or_dict.get("legend", DEFAULT_LEGEND) - - # process the new(?) data - self._parse() - - def get_node_from_coord(self, coords): - """ - Get a MapNode from a coordinate. - - Args: - coords (tuple): X,Y coordinates on XYgrid. - - Returns: - MapNode: The node found at the given coordinates. Returns - `None` if there is no mapnode at the given coordinate. - - Raises: - MapError: If trying to specify an iX,iY outside - of the grid's maximum bounds. - - """ - if not self.XYgrid: - self.parse() - - iX, iY = coords - if not ((0 <= iX <= self.max_X) and (0 <= iY <= self.max_Y)): - raise MapError(f"get_node_from_coord got coordinate {coords} which is " - f"outside the grid size of (0,0) - ({self.max_X}, {self.max_Y}).") - try: - return self.XYgrid[coords[0]][coords[1]] - except KeyError: - return None - - def get_shortest_path(self, startcoord, endcoord): - """ - Get the shortest route between two points on the grid. - - Args: - startcoord (tuple): A starting (X,Y) coordinate on the XYgrid (in-game coordinate) for - where we start from. - endcoord (tuple or MapNode): The end (X,Y) coordinate on the XYgrid (in-game coordinate) - we want to find the shortest route to. - - Returns: - tuple: Two lists, first containing the list of directions as strings (n, ne etc) and - the second is a mixed list of MapNodes and string-directions in a sequence describing - the full path including the start- and end-node. - - """ - startnode = self.get_node_from_coord(startcoord) - endnode = self.get_node_from_coord(endcoord) - - if not (startnode and endnode): - # no node at given coordinate. No path is possible. - return [], [] - - try: - istartnode = startnode.node_index - inextnode = endnode.node_index - except AttributeError: - raise MapError(f"Map.get_shortest_path received start/end nodes {startnode} and " - f"{endnode}. They must both be MapNodes (not Links)") - - if self.pathfinding_routes is None: - self._calculate_path_matrix() - - pathfinding_routes = self.pathfinding_routes - node_index_map = self.node_index_map - - path = [endnode] - directions = [] - - while pathfinding_routes[istartnode, inextnode] != -9999: - # the -9999 is set by algorithm for unreachable nodes or if trying - # to go a node we are already at (the start node in this case since - # we are working backwards). - inextnode = pathfinding_routes[istartnode, inextnode] - nextnode = node_index_map[inextnode] - shortest_route_to = nextnode.shortest_route_to_node[path[-1].node_index] - - directions.append(shortest_route_to[0]) - path.extend(shortest_route_to[1][::-1] + [nextnode]) - - if any(1 for step in shortest_route_to[1] if step.interrupt_path): - # detected an interrupt in linkage - discard what we have so far - directions = [] - path = [nextnode] - - if nextnode.interrupt_path and nextnode is not startnode: - directions = [] - path = [nextnode] - - # we have the path - reverse to get the correct order - directions = directions[::-1] - path = path[::-1] - - return directions, path - - def get_visual_range(self, coord, dist=2, mode='nodes', - character='@', - target=None, target_path_style="|y{display_symbol}|n", - max_size=None, - return_str=True): - """ - Get a part of the grid centered on a specific point and extended a certain number - of nodes or grid points in every direction. - - Args: - coord (tuple): (X,Y) in-world coordinate location. If this is not the location - of a node on the grid, the `character` or the empty-space symbol (by default - an empty space) will be shown. - dist (int, optional): Number of gridpoints distance to show. Which - grid to use depends on the setting of `only_nodes`. Set to `None` to - always show the entire grid. - mode (str, optional): One of 'scan' or 'nodes'. In 'scan' mode, dist measure - number of xy grid points in all directions and doesn't care about if visible - nodes are reachable or not. If 'nodes', distance measure how many linked nodes - away from the center coordinate to display. - character (str, optional): Place this symbol at the `coord` position - of the displayed map. The center node' symbol is shown if this is falsy. - target (tuple, optional): A target XY coordinate to go to. The path to this - (or the beginning of said path, if outside of visual range) will be - marked according to `target_path_style`. - target_path_style (str or callable, optional): This is use for marking the path - found when `path_to_coord` is given. If a string, it accepts a formatting marker - `display_symbol` which will be filled with the `display_symbol` of each node/link - the path passes through. This allows e.g. to color the path. If a callable, this - will receive the MapNode or MapLink object for every step of the path and and - must return the suitable string to display at the position of the node/link. - max_size (tuple, optional): A max `(width, height)` to crop the displayed - return to. Make both odd numbers to get a perfect center. - If unset, display-size can grow up to the full size of the grid. - return_str (bool, optional): Return result as an already formatted string - or a 2D list. - - Returns: - str or list: Depending on value of `return_str`. If a list, - this is 2D grid of lines, [[str,str,str,...], [...]] where - each element is a single character in the display grid. To - extract a character at (ix,iy) coordinate from it, use - indexing `outlist[iy][ix]` in that order. - - Notes: - If outputting a list, the y-axis must first be reversed before printing since printing - happens top-bottom and the y coordinate system goes bottom-up. This can be done simply - with this before building the final string to send/print. - - printable_order_list = outlist[::-1] - - If mode='nodes', a `dist` of 2 will give the following result in a row of nodes: - - #-#-@----------#-# - - This display may thus visually grow much bigger than expected (both horizontally and - vertically). consider setting `max_size` if wanting to restrict the display size. Also - note that link 'weights' are *included* in this estimate, so if links have weights > 1, - fewer nodes may be found for a given `dist`. - - If mode=`scan`, a dist of 2 on the above example would instead give - - #-@-- - - This mode simply shows a cut-out subsection of the map you are on. The `dist` is - measured on xygrid, so two steps per XY coordinate. It does not consider links or - weights and may also show nodes not actually reachable at the moment: - - | | - # @-# - - """ - iX, iY = coord - # convert inputs to xygrid - width, height = self.max_x + 1, self.max_y + 1 - ix, iy = max(0, min(iX * 2, width)), max(0, min(iY * 2, height)) - display_map = self.display_map - xmin, xmax, ymin, ymax = 0, width - 1, 0, height - 1 - - if dist is None: - # show the entire grid - gridmap = self.display_map - ixc, iyc = ix, iy - - elif dist is None or dist <= 0 or not self.get_node_from_coord(coord): - # There is no node at these coordinates. Show - # nothing but ourselves or emptiness - return character if character else self.empty_symbol - - elif mode == 'nodes': - # dist measures only full, reachable nodes. - points, xmin, xmax, ymin, ymax = self._get_topology_around_coord(coord, dist=dist) - - ixc, iyc = ix - xmin, iy - ymin - # note - override width/height here since our grid is - # now different from the original for future cropping - width, height = xmax - xmin + 1, ymax - ymin + 1 - gridmap = [[" "] * width for _ in range(height)] - for (ix0, iy0) in points: - gridmap[iy0 - ymin][ix0 - xmin] = display_map[iy0][ix0] - - elif mode == 'scan': - # scan-mode - dist measures individual grid points - - xmin, xmax = max(0, ix - dist), min(width, ix + dist + 1) - ymin, ymax = max(0, iy - dist), min(height, iy + dist + 1) - ixc, iyc = ix - xmin, iy - ymin - gridmap = [line[xmin:xmax] for line in display_map[ymin:ymax]] - - else: - raise MapError(f"Map.get_visual_range 'mode' was '{mode}' " - "- it must be either 'scan' or 'nodes'.") - if character: - gridmap[iyc][ixc] = character # correct indexing; it's a list of lines - - if target: - # stylize path to target - - def _default_callable(node): - return target_path_style.format( - display_symbol=node.get_display_symbol(self.xygrid)) - - if callable(target_path_style): - _target_path_style = target_path_style - else: - _target_path_style = _default_callable - - _, path = self.get_shortest_path(coord, target) - - maxstep = dist if mode == 'nodes' else dist / 2 - nsteps = 0 - for node_or_link in path[1:]: - if hasattr(node_or_link, "node_index"): - nsteps += 1 - if nsteps >= maxstep: - break - # don't decorate current (character?) location - ix, iy = node_or_link.x, node_or_link.y - if xmin <= ix <= xmax and ymin <= iy <= ymax: - gridmap[iy - ymin][ix - xmin] = _target_path_style(node_or_link) - - if max_size: - # crop grid to make sure it doesn't grow too far - max_x, max_y = max_size - xmin, xmax = max(0, ixc - max_x // 2), min(width, ixc + max_x // 2 + 1) - ymin, ymax = max(0, iyc - max_y // 2), min(height, iyc + max_y // 2 + 1) - gridmap = [line[xmin:xmax] for line in gridmap[ymin:ymax]] - - if return_str: - # we must flip the y-axis before returning the string - return "\n".join("".join(line) for line in gridmap[::-1]) - else: - return gridmap diff --git a/evennia/contrib/mapsystem/map_single.py b/evennia/contrib/mapsystem/map_single.py new file mode 100644 index 0000000000..34f36623a4 --- /dev/null +++ b/evennia/contrib/mapsystem/map_single.py @@ -0,0 +1,756 @@ +r""" +# Map + +The `Map` class represents one XY-grid of interconnected map-legend components. It's built from an +ASCII representation, where unique characters represents each type of component. The Map parses the +map into an internal graph that can be efficiently used for pathfinding the shortest route between +any two nodes (rooms). + +Each room (MapNode) can have exits (links) in 8 cardinal directions (north, northwest etc) as well +as up and down. These are indicated in code as 'n', 'ne', 'e', 'se', 's', 'sw', 'w', +'nw', 'u' and 'd'. + + +```python + # in module passed to 'Map' class. It will either a dict + # MAP_DATA with keys 'map' and (optionally) 'legend', or + # the MAP/LEGEND variables directly. + + MAP = r''' + 1 + + 0 1 2 3 4 5 6 7 8 9 0 + + 10 # # # # # + \ I I I d + 9 #-#-#-# | + |\ | u + 8 #-#-#-#-----#-----o + | | | + 7 #-#---#-#-#-#-# | + | |x|x| | + 6 o-#-#-# #-#-#-#-# + \ |x|x| + 5 o---#-#<--#-#-# + / | + 4 #-----+-# #---# + \ | | \ / + 3 #b#-#-# x # + | | / \ u + 2 #-#-#---# + ^ d + 1 #-# # + | + 0 #-#---o + + + 0 1 2 3 4 5 6 7 8 9 1 + 0 + + ''' + + LEGEND = {'#': mapsystem.MapNode, '|': mapsystem.NSMapLink,...} + + # optional, for more control + MAP_DATA = { + "map": MAP, + "legend": LEGEND, + } + +``` + +The two `+` signs in the upper/lower left corners are required and marks the edge of the map area. +The origo of the grid is always two steps right and two up from the bottom test marker and the grid +extends to two lines below the top-left marker. Anything outside the grid is ignored, so numbering +the coordinate axes is optional but recommended for readability. + +The XY positions represent coordinates positions in the game world. When existing, they are usually +represented by Rooms in-game. The links between nodes would normally represent Exits, but the length +of links on the map have no in-game equivalence except that traversing a multi-step link will place +you in a location with an XY coordinate different from what you'd expect by a single step (most +games don't relay the XY position to the player anyway). + +In the map string, every XY coordinate must have exactly one spare space/line between them - this is +used for node linkings. This finer grid which has 2x resolution of the `XYgrid` is only used by the +mapper and is referred to as the `xygrid` (small xy) internally. Note that an XY position can also +be held by a link (for example a passthrough). + +The nodes and links can be customized by add your own implementation of `MapNode` or `MapLink` to +the LEGEND dict, mapping them to a particular character symbol. A `MapNode` can only be added +on an even XY coordinate while `MapLink`s can be added anywhere on the xygrid. + +See `./map_example.py` for some empty grid areas to start from. + +---- +""" +import pickle +from collections import defaultdict +from os import mkdir +from os.path import isdir, isfile, join as pathjoin + +try: + from scipy.sparse.csgraph import dijkstra + from scipy.sparse import csr_matrix + from scipy import zeros +except ImportError as err: + raise ImportError( + f"{err}\nThe MapSystem contrib requires " + "the SciPy package. Install with `pip install scipy'.") +from django.conf import settings +from evennia.utils.utils import variable_from_module, mod_import +from evennia.utils import logger + +from .utils import MapError, MapParserError, BIGVAL +from . import map_legend + +_CACHE_DIR = settings.CACHE_DIR + + +# these are all symbols used for x,y coordinate spots +DEFAULT_LEGEND = { + "#": map_legend.BasicMapNode, + "I": map_legend.InterruptMapNode, + "|": map_legend.NSMapLink, + "-": map_legend.EWMapLink, + "/": map_legend.NESWMapLink, + "\\": map_legend.SENWMapLink, + "x": map_legend.CrossMapLink, + "+": map_legend.PlusMapLink, + "v": map_legend.NSOneWayMapLink, + "^": map_legend.SNOneWayMapLink, + "<": map_legend.EWOneWayMapLink, + ">": map_legend.WEOneWayMapLink, + "o": map_legend.RouterMapLink, + "u": map_legend.UpMapLink, + "d": map_legend.DownMapLink, + "b": map_legend.BlockedMapLink, + "i": map_legend.InterruptMapLink, + 't': map_legend.TeleporterMapLink, + 'T': map_legend.MapTransitionLink, +} + +# -------------------------------------------- +# Map parser implementation + + +class SingleMap: + r""" + This represents a single map of interconnected nodes/rooms, parsed from a ASCII map + representation. + + Each room is connected to each other as a directed graph with optional 'weights' between the the + connections. It is created from a map string with symbols describing the topological layout. It + also provides pathfinding using the Dijkstra algorithm. + + The map-string is read from a string or from a module. The grid area of the string is marked by + two `+` characters - one in the top left of the area and the other in the bottom left. + The grid starts two spaces/lines in from the 'open box' created by these two markers and extend + any width to the right. + Any other markers or comments can be added outside of the grid - they will be ignored. Every + grid coordinate must always be separated by exactly one space/line since the space between + are used for links. + :: + ''' + 1 1 1 + + 0 1 2 3 4 5 6 7 8 9 0 1 2 ... + + 4 # # # + | \ / + 3 #-#-# # # + | \ / + 2 #-#-# # + |x|x| | + 1 #-#-#-#-#-#-# + / + 0 #-# + + + 0 1 2 3 4 5 6 7 8 9 1 1 1 ... + 0 1 2 + ''' + + So origo (0,0) is in the bottom-left and north is +y movement, south is -y movement + while east/west is +/- x movement as expected. Adding numbers to axes is optional + but recommended for readability! + + """ + mapcorner_symbol = '+' + max_pathfinding_length = 500 + empty_symbol = ' ' + # we normally only accept one single character for the legend key + legend_key_exceptions = ("\\") + + def __init__(self, map_module_or_dict, name="map"): + """ + Initialize the map parser by feeding it the map. + + Args: + map_module_or_dict (str, module or dict): Path or module pointing to a map. If a dict, + this should be a dict with a key 'map' and optionally a 'legend' + dicts to specify the map structure. + name (str, optional): Unique identifier for this map. Needed if the game uses + more than one map. Used when referencing this map during map transitions, + baking of pathfinding matrices etc. + + Notes: + The map deals with two sets of coorinate systems: + - grid-coordinates x,y are the character positions in the map string. + - world-coordinates X,Y are the in-world coordinates of nodes/rooms. + There are fewer of these since they ignore the 'link' spaces between + the nodes in the grid, so + + X = x // 2 + Y = y // 2 + + """ + self.name = name + + self.mapstring = "" + + # store so we can reload + self.map_module_or_dict = map_module_or_dict + + # map setup + self.xygrid = None + self.XYgrid = None + self.display_map = None + self.max_x = 0 + self.max_y = 0 + self.max_X = 0 + self.max_Y = 0 + + # Dijkstra algorithm variables + self.node_index_map = None + self.dist_matrix = None + self.pathfinding_routes = None + + self.pathfinder_baked_filename = None + if name: + if not isdir(_CACHE_DIR): + mkdir(_CACHE_DIR) + self.pathfinder_baked_filename = pathjoin(_CACHE_DIR, f"{name}.P") + + # load data and parse it + self.reload() + + def __str__(self): + """ + Print the string representation of the map. + Since the y-axes origo is at the bottom, we must flip the + y-axis before printing (since printing is always top-to-bottom). + + """ + return "\n".join("".join(line) for line in self.display_map[::-1]) + + def __repr__(self): + return f"" + + def _get_topology_around_coord(self, coord, dist=2): + """ + Get all links and nodes up to a certain distance from an XY coordinate. + + Args: + coord (tuple), the X,Y coordinate of the center point. + dist (int): How many nodes away from center point to find paths for. + + Returns: + tuple: A tuple of 5 elements `(coords, xmin, xmax, ymin, ymax)`, where the + first element is a list of xy-coordinates (on xygrid) for all linked nodes within + range. This is meant to be used with the xygrid for extracting a subset + for display purposes. The others are the minimum size of the rectangle + surrounding the area containing `coords`. + + Notes: + This performs a depth-first pass down the the given dist. + + """ + def _scan_neighbors(start_node, points, dist=2, + xmin=BIGVAL, ymin=BIGVAL, xmax=0, ymax=0, depth=0): + + x0, y0 = start_node.x, start_node.y + points.append((x0, y0)) + xmin, xmax = min(xmin, x0), max(xmax, x0) + ymin, ymax = min(ymin, y0), max(ymax, y0) + + if depth < dist: + # keep stepping + for direction, end_node in start_node.links.items(): + x, y = x0, y0 + for link in start_node.xy_steps_to_node[direction]: + x, y = link.x, link.y + points.append((x, y)) + xmin, xmax = min(xmin, x), max(xmax, x) + ymin, ymax = min(ymin, y), max(ymax, y) + + points, xmin, xmax, ymin, ymax = _scan_neighbors( + end_node, points, dist=dist, + xmin=xmin, ymin=ymin, xmax=xmax, ymax=ymax, + depth=depth + 1) + + return points, xmin, xmax, ymin, ymax + + center_node = self.get_node_from_coord(coord) + points, xmin, xmax, ymin, ymax = _scan_neighbors(center_node, [], dist=dist) + return list(set(points)), xmin, xmax, ymin, ymax + + def _calculate_path_matrix(self): + """ + Solve the pathfinding problem using Dijkstra's algorithm. This will try to + load the solution from disk if possible. + + """ + if self.pathfinder_baked_filename and isfile(self.pathfinder_baked_filename): + # check if the solution for this grid was already solved previously. + + mapstr, dist_matrix, pathfinding_routes = "", None, None + with open(self.pathfinder_baked_filename, 'rb') as fil: + try: + mapstr, dist_matrix, pathfinding_routes = pickle.load(fil) + except Exception: + logger.log_trace() + if (mapstr == self.mapstring + and dist_matrix is not None + and pathfinding_routes is not None): + # this is important - it means the map hasn't changed so + # we can re-use the stored data! + self.dist_matrix = dist_matrix + self.pathfinding_routes = pathfinding_routes + return + + # build a matrix representing the map graph, with 0s as impassable areas + + nnodes = len(self.node_index_map) + pathfinding_graph = zeros((nnodes, nnodes)) + for inode, node in self.node_index_map.items(): + pathfinding_graph[inode, :] = node.linkweights(nnodes) + + # create a sparse matrix to represent link relationships from each node + pathfinding_matrix = csr_matrix(pathfinding_graph) + + # solve using Dijkstra's algorithm + self.dist_matrix, self.pathfinding_routes = dijkstra( + pathfinding_matrix, directed=True, + return_predecessors=True, limit=self.max_pathfinding_length) + + if self.pathfinder_baked_filename: + # try to cache the results + with open(self.pathfinder_baked_filename, 'wb') as fil: + pickle.dump((self.mapstring, self.dist_matrix, self.pathfinding_routes), + fil, protocol=4) + + def _parse(self): + """ + Parses the numerical grid from the string. The result of this is a 2D array + of [[MapNode,...], [MapNode, ...]] with MapLinks inside them describing their + linkage to other nodes. See the class docstring for details of how the grid + should be defined. + + Notes: + In this parsing, the 'xygrid' is the full range of chraracters read from + the string. The `XYgrid` is used to denote the game-world coordinates + (which doesn't include the links) + + """ + mapcorner_symbol = self.mapcorner_symbol + # this allows for string-based [x][y] mapping with arbitrary objects + xygrid = defaultdict(dict) + # mapping nodes to real X,Y positions + XYgrid = defaultdict(dict) + # needed by pathfinder + node_index_map = {} + + mapstring = self.mapstring + if mapstring.count(mapcorner_symbol) < 2: + raise MapParserError( + f"The mapstring must have at least two '{mapcorner_symbol}' " + "symbols marking the upper- and bottom-left corners of the " + "grid area.") + + # find the the position (in the string as a whole) of the top-left corner-marker + maplines = mapstring.split("\n") + topleft_marker_x, topleft_marker_y = -1, -1 + for topleft_marker_y, line in enumerate(maplines): + topleft_marker_x = line.find(mapcorner_symbol) + if topleft_marker_x != -1: + break + if -1 in (topleft_marker_x, topleft_marker_y): + raise MapParserError(f"No top-left corner-marker ({mapcorner_symbol}) found!") + + # find the position (in the string as a whole) of the bottom-left corner-marker + # this is always in a stright line down from the first marker + botleft_marker_x, botleft_marker_y = topleft_marker_x, -1 + for botleft_marker_y, line in enumerate(maplines[topleft_marker_y + 1:]): + if line.find(mapcorner_symbol) == topleft_marker_x: + break + if botleft_marker_y == -1: + raise MapParserError(f"No bottom-left corner-marker ({mapcorner_symbol}) found! " + "Make sure it lines up with the top-left corner-marker " + f"(found at column {topleft_marker_x} of the string).") + # the actual coordinate is dy below the topleft marker so we need to shift + botleft_marker_y += topleft_marker_y + 1 + + # in-string_position of the top- and bottom-left grid corners (2 steps in from marker) + # the bottom-left corner is also the origo (0,0) of the grid. + topleft_y = topleft_marker_y + 2 + origo_x, origo_y = botleft_marker_x + 2, botleft_marker_y - 1 + + # highest actually filled grid points + max_x = 0 + max_y = 0 + max_X = 0 + max_Y = 0 + node_index = -1 + + # first pass: read string-grid (left-right, bottom-up) and parse all grid points + for iy, line in enumerate(reversed(maplines[topleft_y:origo_y])): + even_iy = iy % 2 == 0 + for ix, char in enumerate(line[origo_x:]): + # from now on, coordinates are on the xygrid. + + if char == self.empty_symbol: + continue + + # only set this if there's actually something on the line + max_x, max_y = max(max_x, ix), max(max_y, iy) + + mapnode_or_link_class = self.legend.get(char) + if not mapnode_or_link_class: + raise MapParserError( + f"Symbol '{char}' on XY=({ix / 2:g},{iy / 2:g}) " + "is not found in LEGEND." + ) + if hasattr(mapnode_or_link_class, "node_index"): + # A mapnode. Mapnodes can only be placed on even grid positions, where + # there are integer X,Y coordinates defined. + + if not (even_iy and ix % 2 == 0): + raise MapParserError( + f"Symbol '{char}' on XY=({ix / 2:g},{iy / 2:g}) marks a " + "MapNode but is located between integer (X,Y) positions (only " + "Links can be placed between coordinates)!") + + # save the node to several different maps for different uses + # in both coordinate systems + iX, iY = ix // 2, iy // 2 + max_X, max_Y = max(max_X, iX), max(max_Y, iY) + node_index += 1 + + xygrid[ix][iy] = XYgrid[iX][iY] = node_index_map[node_index] = \ + mapnode_or_link_class(node_index=node_index, x=ix, y=iy) + + else: + # we have a link at this xygrid position (this is ok everywhere) + xygrid[ix][iy] = mapnode_or_link_class(ix, iy) + + # second pass: Here we loop over all nodes and have them connect to each other + # via the detected linkages. + for node in node_index_map.values(): + node.scan_all_directions(xygrid) + + # build display map + display_map = [[" "] * (max_x + 1) for _ in range(max_y + 1)] + for ix, ydct in xygrid.items(): + for iy, node_or_link in ydct.items(): + display_map[iy][ix] = node_or_link.get_display_symbol(xygrid, mapinstance=self) + + # store + self.max_x, self.max_y = max_x, max_y + self.xygrid = xygrid + + self.max_X, self.max_Y = max_X, max_Y + self.XYgrid = XYgrid + + self.node_index_map = node_index_map + self.display_map = display_map + + def reload(self, map_module_or_dict=None): + """ + (Re)Load a map. + + Args: + map_module_or_dict (str, module or dict, optional): See description for the variable + in the class' `__init__` function. If given, replace the already loaded + map with a new one. If not given, the existing one given on class creation + will be reloaded. + parse (bool, optional): If set, auto-run `.parse()` on the newly loaded data. + + Notes: + This will both (re)load the data and parse it into a new map structure, replacing any + existing one. + + """ + if not map_module_or_dict: + map_module_or_dict = self.map_module_or_dict + + mapdata = {} + if isinstance(map_module_or_dict, dict): + mapdata = map_module_or_dict + else: + mod = mod_import(map_module_or_dict) + mapdata = variable_from_module(mod, "MAP_DATA") + if not mapdata: + mapdata['map'] = variable_from_module(mod, "MAP") + mapdata['legend'] = variable_from_module(mod, "LEGEND", default=DEFAULT_LEGEND) + + # validate + for key in mapdata.get('legend', DEFAULT_LEGEND): + if not key or len(key) > 1: + if key not in self.legend_key_exceptions: + raise MapError(f"Map-legend key '{key}' is invalid: All keys must " + "be exactly one character long. Use the node/link's " + "`.display_symbol` property to change how it is " + "displayed.") + if 'map' not in mapdata or not mapdata['map']: + raise MapError("No map found. Add 'map' key to map-data (MAP_DATA) dict or " + "add variable MAP to a module passed into the parser.") + + # store/update result + self.mapstring = mapdata['map'] + self.legend = map_module_or_dict.get("legend", DEFAULT_LEGEND) + + # process the new(?) data + self._parse() + + def get_node_from_coord(self, coords): + """ + Get a MapNode from a coordinate. + + Args: + coords (tuple): X,Y coordinates on XYgrid. + + Returns: + MapNode: The node found at the given coordinates. Returns + `None` if there is no mapnode at the given coordinate. + + Raises: + MapError: If trying to specify an iX,iY outside + of the grid's maximum bounds. + + """ + if not self.XYgrid: + self.parse() + + iX, iY = coords + if not ((0 <= iX <= self.max_X) and (0 <= iY <= self.max_Y)): + raise MapError(f"get_node_from_coord got coordinate {coords} which is " + f"outside the grid size of (0,0) - ({self.max_X}, {self.max_Y}).") + try: + return self.XYgrid[coords[0]][coords[1]] + except KeyError: + return None + + def get_shortest_path(self, startcoord, endcoord): + """ + Get the shortest route between two points on the grid. + + Args: + startcoord (tuple): A starting (X,Y) coordinate on the XYgrid (in-game coordinate) for + where we start from. + endcoord (tuple or MapNode): The end (X,Y) coordinate on the XYgrid (in-game coordinate) + we want to find the shortest route to. + + Returns: + tuple: Two lists, first containing the list of directions as strings (n, ne etc) and + the second is a mixed list of MapNodes and string-directions in a sequence describing + the full path including the start- and end-node. + + """ + startnode = self.get_node_from_coord(startcoord) + endnode = self.get_node_from_coord(endcoord) + + if not (startnode and endnode): + # no node at given coordinate. No path is possible. + return [], [] + + try: + istartnode = startnode.node_index + inextnode = endnode.node_index + except AttributeError: + raise MapError(f"Map.get_shortest_path received start/end nodes {startnode} and " + f"{endnode}. They must both be MapNodes (not Links)") + + if self.pathfinding_routes is None: + self._calculate_path_matrix() + + pathfinding_routes = self.pathfinding_routes + node_index_map = self.node_index_map + + path = [endnode] + directions = [] + + while pathfinding_routes[istartnode, inextnode] != -9999: + # the -9999 is set by algorithm for unreachable nodes or if trying + # to go a node we are already at (the start node in this case since + # we are working backwards). + inextnode = pathfinding_routes[istartnode, inextnode] + nextnode = node_index_map[inextnode] + shortest_route_to = nextnode.shortest_route_to_node[path[-1].node_index] + + directions.append(shortest_route_to[0]) + path.extend(shortest_route_to[1][::-1] + [nextnode]) + + if any(1 for step in shortest_route_to[1] if step.interrupt_path): + # detected an interrupt in linkage - discard what we have so far + directions = [] + path = [nextnode] + + if nextnode.interrupt_path and nextnode is not startnode: + directions = [] + path = [nextnode] + + # we have the path - reverse to get the correct order + directions = directions[::-1] + path = path[::-1] + + return directions, path + + def get_visual_range(self, coord, dist=2, mode='nodes', + character='@', + target=None, target_path_style="|y{display_symbol}|n", + max_size=None, + return_str=True): + """ + Get a part of the grid centered on a specific point and extended a certain number + of nodes or grid points in every direction. + + Args: + coord (tuple): (X,Y) in-world coordinate location. If this is not the location + of a node on the grid, the `character` or the empty-space symbol (by default + an empty space) will be shown. + dist (int, optional): Number of gridpoints distance to show. Which + grid to use depends on the setting of `only_nodes`. Set to `None` to + always show the entire grid. + mode (str, optional): One of 'scan' or 'nodes'. In 'scan' mode, dist measure + number of xy grid points in all directions and doesn't care about if visible + nodes are reachable or not. If 'nodes', distance measure how many linked nodes + away from the center coordinate to display. + character (str, optional): Place this symbol at the `coord` position + of the displayed map. The center node' symbol is shown if this is falsy. + target (tuple, optional): A target XY coordinate to go to. The path to this + (or the beginning of said path, if outside of visual range) will be + marked according to `target_path_style`. + target_path_style (str or callable, optional): This is use for marking the path + found when `path_to_coord` is given. If a string, it accepts a formatting marker + `display_symbol` which will be filled with the `display_symbol` of each node/link + the path passes through. This allows e.g. to color the path. If a callable, this + will receive the MapNode or MapLink object for every step of the path and and + must return the suitable string to display at the position of the node/link. + max_size (tuple, optional): A max `(width, height)` to crop the displayed + return to. Make both odd numbers to get a perfect center. + If unset, display-size can grow up to the full size of the grid. + return_str (bool, optional): Return result as an already formatted string + or a 2D list. + + Returns: + str or list: Depending on value of `return_str`. If a list, + this is 2D grid of lines, [[str,str,str,...], [...]] where + each element is a single character in the display grid. To + extract a character at (ix,iy) coordinate from it, use + indexing `outlist[iy][ix]` in that order. + + Notes: + If outputting a list, the y-axis must first be reversed before printing since printing + happens top-bottom and the y coordinate system goes bottom-up. This can be done simply + with this before building the final string to send/print. + + printable_order_list = outlist[::-1] + + If mode='nodes', a `dist` of 2 will give the following result in a row of nodes: + + #-#-@----------#-# + + This display may thus visually grow much bigger than expected (both horizontally and + vertically). consider setting `max_size` if wanting to restrict the display size. Also + note that link 'weights' are *included* in this estimate, so if links have weights > 1, + fewer nodes may be found for a given `dist`. + + If mode=`scan`, a dist of 2 on the above example would instead give + + #-@-- + + This mode simply shows a cut-out subsection of the map you are on. The `dist` is + measured on xygrid, so two steps per XY coordinate. It does not consider links or + weights and may also show nodes not actually reachable at the moment: + + | | + # @-# + + """ + iX, iY = coord + # convert inputs to xygrid + width, height = self.max_x + 1, self.max_y + 1 + ix, iy = max(0, min(iX * 2, width)), max(0, min(iY * 2, height)) + display_map = self.display_map + xmin, xmax, ymin, ymax = 0, width - 1, 0, height - 1 + + if dist is None: + # show the entire grid + gridmap = self.display_map + ixc, iyc = ix, iy + + elif dist is None or dist <= 0 or not self.get_node_from_coord(coord): + # There is no node at these coordinates. Show + # nothing but ourselves or emptiness + return character if character else self.empty_symbol + + elif mode == 'nodes': + # dist measures only full, reachable nodes. + points, xmin, xmax, ymin, ymax = self._get_topology_around_coord(coord, dist=dist) + + ixc, iyc = ix - xmin, iy - ymin + # note - override width/height here since our grid is + # now different from the original for future cropping + width, height = xmax - xmin + 1, ymax - ymin + 1 + gridmap = [[" "] * width for _ in range(height)] + for (ix0, iy0) in points: + gridmap[iy0 - ymin][ix0 - xmin] = display_map[iy0][ix0] + + elif mode == 'scan': + # scan-mode - dist measures individual grid points + + xmin, xmax = max(0, ix - dist), min(width, ix + dist + 1) + ymin, ymax = max(0, iy - dist), min(height, iy + dist + 1) + ixc, iyc = ix - xmin, iy - ymin + gridmap = [line[xmin:xmax] for line in display_map[ymin:ymax]] + + else: + raise MapError(f"Map.get_visual_range 'mode' was '{mode}' " + "- it must be either 'scan' or 'nodes'.") + if character: + gridmap[iyc][ixc] = character # correct indexing; it's a list of lines + + if target: + # stylize path to target + + def _default_callable(node): + return target_path_style.format( + display_symbol=node.get_display_symbol(self.xygrid)) + + if callable(target_path_style): + _target_path_style = target_path_style + else: + _target_path_style = _default_callable + + _, path = self.get_shortest_path(coord, target) + + maxstep = dist if mode == 'nodes' else dist / 2 + nsteps = 0 + for node_or_link in path[1:]: + if hasattr(node_or_link, "node_index"): + nsteps += 1 + if nsteps >= maxstep: + break + # don't decorate current (character?) location + ix, iy = node_or_link.x, node_or_link.y + if xmin <= ix <= xmax and ymin <= iy <= ymax: + gridmap[iy - ymin][ix - xmin] = _target_path_style(node_or_link) + + if max_size: + # crop grid to make sure it doesn't grow too far + max_x, max_y = max_size + xmin, xmax = max(0, ixc - max_x // 2), min(width, ixc + max_x // 2 + 1) + ymin, ymax = max(0, iyc - max_y // 2), min(height, iyc + max_y // 2 + 1) + gridmap = [line[xmin:xmax] for line in gridmap[ymin:ymax]] + + if return_str: + # we must flip the y-axis before returning the string + return "\n".join("".join(line) for line in gridmap[::-1]) + else: + return gridmap diff --git a/evennia/contrib/map_and_pathfind/tests.py b/evennia/contrib/mapsystem/tests.py similarity index 96% rename from evennia/contrib/map_and_pathfind/tests.py rename to evennia/contrib/mapsystem/tests.py index 569626fe48..42baca1d8f 100644 --- a/evennia/contrib/map_and_pathfind/tests.py +++ b/evennia/contrib/mapsystem/tests.py @@ -8,7 +8,7 @@ from time import time from random import randint from unittest import TestCase from parameterized import parameterized -from . import mapsystem +from . import map_single MAP1 = """ @@ -320,7 +320,7 @@ class TestMap1(TestCase): """ def setUp(self): - self.map = mapsystem.Map({"map": MAP1}, name="testmap") + self.map = map_single.SingleMap({"map": MAP1}, name="testmap") def test_str_output(self): """Check the display_map""" @@ -404,7 +404,7 @@ class TestMap2(TestCase): """ def setUp(self): - self.map = mapsystem.Map({"map": MAP2}, name="testmap") + self.map = map_single.SingleMap({"map": MAP2}, name="testmap") def test_str_output(self): """Check the display_map""" @@ -514,7 +514,7 @@ class TestMap3(TestCase): """ def setUp(self): - self.map = mapsystem.Map({"map": MAP3}, name="testmap") + self.map = map_single.SingleMap({"map": MAP3}, name="testmap") def test_str_output(self): """Check the display_map""" @@ -563,7 +563,7 @@ class TestMap4(TestCase): """ def setUp(self): - self.map = mapsystem.Map({"map": MAP4}, name="testmap") + self.map = map_single.SingleMap({"map": MAP4}, name="testmap") def test_str_output(self): """Check the display_map""" @@ -593,7 +593,7 @@ class TestMap5(TestCase): """ def setUp(self): - self.map = mapsystem.Map({"map": MAP5}, name="testmap") + self.map = map_single.SingleMap({"map": MAP5}, name="testmap") def test_str_output(self): """Check the display_map""" @@ -621,7 +621,7 @@ class TestMap6(TestCase): """ def setUp(self): - self.map = mapsystem.Map({"map": MAP6}, name="testmap") + self.map = map_single.SingleMap({"map": MAP6}, name="testmap") def test_str_output(self): """Check the display_map""" @@ -653,7 +653,7 @@ class TestMap7(TestCase): """ def setUp(self): - self.map = mapsystem.Map({"map": MAP7}, name="testmap") + self.map = map_single.SingleMap({"map": MAP7}, name="testmap") def test_str_output(self): """Check the display_map""" @@ -681,7 +681,7 @@ class TestMap8(TestCase): """ def setUp(self): - self.map = mapsystem.Map({"map": MAP8}, name="testmap") + self.map = map_single.SingleMap({"map": MAP8}, name="testmap") def test_str_output(self): """Check the display_map""" @@ -747,7 +747,7 @@ class TestMap9(TestCase): """ def setUp(self): - self.map = mapsystem.Map({"map": MAP9}, name="testmap") + self.map = map_single.SingleMap({"map": MAP9}, name="testmap") def test_str_output(self): """Check the display_map""" @@ -776,7 +776,7 @@ class TestMap10(TestCase): """ def setUp(self): - self.map = mapsystem.Map({"map": MAP10}, name="testmap") + self.map = map_single.SingleMap({"map": MAP10}, name="testmap") def test_str_output(self): """Check the display_map""" @@ -824,7 +824,7 @@ class TestMap11(TestCase): """ def setUp(self): - self.map = mapsystem.Map({"map": MAP11}, name="testmap") + self.map = map_single.SingleMap({"map": MAP11}, name="testmap") def test_str_output(self): """Check the display_map""" @@ -914,7 +914,7 @@ class TestMapStressTest(TestCase): grid = self._get_grid(Xmax, Ymax) # print(f"\n\n{grid}\n") t0 = time() - mapsystem.Map({'map': grid}, name="testmap") + map_single.SingleMap({'map': grid}, name="testmap") t1 = time() self.assertLess(t1 - t0, max_time, f"Map creation of ({Xmax}x{Ymax}) grid slower " f"than expected {max_time}s.") @@ -930,7 +930,7 @@ class TestMapStressTest(TestCase): """ Xmax, Ymax = gridsize grid = self._get_grid(Xmax, Ymax) - mapobj = mapsystem.Map({'map': grid}, name="testmap") + mapobj = map_single.SingleMap({'map': grid}, name="testmap") t0 = time() mapobj._calculate_path_matrix() @@ -962,7 +962,7 @@ class TestMapStressTest(TestCase): """ Xmax, Ymax = gridsize grid = self._get_grid(Xmax, Ymax) - mapobj = mapsystem.Map({'map': grid}, name="testmap") + mapobj = map_single.SingleMap({'map': grid}, name="testmap") t0 = time() mapobj._calculate_path_matrix() diff --git a/evennia/contrib/mapsystem/utils.py b/evennia/contrib/mapsystem/utils.py new file mode 100644 index 0000000000..8883bd8713 --- /dev/null +++ b/evennia/contrib/mapsystem/utils.py @@ -0,0 +1,49 @@ +""" + +Helpers and resources for the map system. + +""" + +BIGVAL = 999999999999 + +REVERSE_DIRECTIONS = { + "n": "s", + "ne": "sw", + "e": "w", + "se": "nw", + "s": "n", + "sw": "ne", + "w": "e", + "nw": "se", +} + +MAPSCAN = { + "n": (0, 1), + "ne": (1, 1), + "e": (1, 0), + "se": (1, -1), + "s": (0, -1), + "sw": (-1, -1), + "w": (-1, 0), + "nw": (-1, 1), +} + +# errors for Map system + +class MapError(RuntimeError): + + def __init__(self, error="", node_or_link=None): + prefix = "" + if node_or_link: + prefix = (f"{node_or_link.__class__.__name__} '{node_or_link.symbol}' " + f"at XY=({node_or_link.X:g},{node_or_link.Y:g}) ") + self.node_or_link = node_or_link + self.message = f"{prefix}{error}" + super().__init__(self.message) + + +class MapParserError(MapError): + pass + + +