diff --git a/evennia/contrib/map_and_pathfind/example_map.py b/evennia/contrib/map_and_pathfind/example_map.py index 24b2301163..2cdd6cb5e1 100644 --- a/evennia/contrib/map_and_pathfind/example_map.py +++ b/evennia/contrib/map_and_pathfind/example_map.py @@ -1,7 +1,7 @@ MAP = r""" - 1 1 1 1 1 1 1 1 1 1 2 - + 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 + 1 + + 0 1 2 3 4 5 6 7 8 9 0 0 # \ @@ -23,27 +23,6 @@ MAP = r""" ^ 9 #-# # -10 - -11 - -12 - -13 - -14 - -15 - -16 - -17 - -18 - -19 - -20 """ @@ -54,3 +33,33 @@ MAP_DATA = { "map": MAP, "legend": LEGEND, } + +MAP = r""" + 1 + + 0 1 2 3 4 5 6 7 8 9 0 + +10 #-#-# + | + 9 # + \ + 8 #-#-#-# + |\ | + 7 #-#+#+#-----# + | | + 6 #-#---#-#-#-#-# + | |x|x| + 5 o-#-#-# #-#-# + \ |x|x| + 4 o-o-#-# #-#-# + / + 3 # + \ + 2 #-#-#-# + | | + 1 #-#-#d# # + ^ + 0 #-# # + + + 0 1 2 3 4 5 6 7 8 9 0 + 1 +""" diff --git a/evennia/contrib/map_and_pathfind/mapsystem.py b/evennia/contrib/map_and_pathfind/mapsystem.py index 719e7ae0aa..988d9bac91 100644 --- a/evennia/contrib/map_and_pathfind/mapsystem.py +++ b/evennia/contrib/map_and_pathfind/mapsystem.py @@ -4,33 +4,38 @@ Implement mapping, with path searching. This builds a map graph based on an ASCII map-string with special, user-defined symbols. ```python - # in module passed to Map class + # 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 - 0 # + 10 # \ - 1 #-#-#-# + 9 #-#-#-# |\ | - 2 #-#-#-#-----# + 8 #-#-#-#-----# | | - 3 #-#---#-#-#-#-# + 7 #-#---#-#-#-#-# | |x|x| - 4 o-#-#-# #-#-# + 6 o-#-#-# #-#-# \ |x|x| 5 o---#-# #-#-# / - 6 # + 4 # \ - 7 #-#-#-# + 3 #-#-#-# | | - 8 #-#-#-# # + 2 #-#-#-# # ^ - 9 #-# # + 1 #-# # + | + 0 #-#---o - 10 + + 0 1 2 3 4 5 6 7 8 9 1 + 0 ''' @@ -44,23 +49,27 @@ This builds a map graph based on an ASCII map-string with special, user-defined ``` +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. - -The single `+` sign in the upper left corner is required and marks the origo of the mapping area and -the 0,0 position will always start one space right and one line down from it. The coordinate axes -numbering is optional, but recommended for readability. - -Every x-column should be spaced with one space and the y-rows must have a line between them. - -The coordinate positions all corresponds to map 'nodes'. These are usually rooms (which require an -in-game coordinate system to work with the map) but can also be abstract 'link nodes' that links -rooms together and have no in-game equivalence. - -All in-between-coordinates positions are reserved for links and no nodes will be detected in those -positions (since it would then not have a proper x,y coordinate). - +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. ---- """ @@ -85,14 +94,14 @@ _REVERSE_DIRECTIONS = { } _MAPSCAN = { - "n": (0, -1), - "ne": (1, -1), + "n": (0, 1), + "ne": (1, 1), "e": (1, 0), - "se": (1, 1), - "s": (0, 1), - "sw": (-1, 1), + "se": (1, -1), + "s": (0, -1), + "sw": (-1, -1), "w": (-1, 0), - "nw": (-1, -1) + "nw": (1, -1) } @@ -126,8 +135,8 @@ class MapNode: Initialize the mapnode. Args: - x (int): X coordinate. This is the actual room coordinate. - y (int): Y coordinate. This is the actual room coordinate. + x (int): Coordinate on xygrid. + y (int): Coordinate on xygrid. node_index (int): This identifies this node with a running index number required for pathfinding. @@ -135,6 +144,11 @@ class MapNode: self.x = x self.y = y + + # XYgrid coordinate + self.X = x // 2 + self.Y = y // 2 + self.node_index = node_index if not self.display_symbol: @@ -148,17 +162,17 @@ class MapNode: # lowest direction to a given neighbor self.cheapest_to_node = {} - def build_links(self, string_map): + def build_links(self, xygrid): """ Start tracking links in all cardinal directions to tie this to another node. Args: - string_map (dict): A 2d dict-of-dicts with x,y coordinates as keys and nodes as values. + xygrid (dict): A 2d dict-of-dicts with x,y coordinates as keys and nodes as values. """ - # convert room-coordinates back to string-map coordinates - x, y = self.x * 2, self.y * 2 + # we must use the xygrid coordinates + x, y = self.x, self.y # scan in all directions for links for direction, (dx, dy) in _MAPSCAN.items(): @@ -168,12 +182,12 @@ class MapNode: # hence we can step in integer steps lx, ly = x + dx, y + dy - if lx in string_map and ly in string_map[lx]: - link = string_map[lx][ly] + if lx in xygrid and ly in xygrid[lx]: + link = xygrid[lx][ly] # just because there is a link here, doesn't mean it's # connected to this node. If so the `end_node` will be None. - end_node, weight = link.traverse(_REVERSE_DIRECTIONS[direction], string_map) + end_node, weight = link.traverse(_REVERSE_DIRECTIONS[direction], xygrid) if end_node: # the link could be followed to an end node! node_index = end_node.node_index @@ -261,24 +275,23 @@ class MapLink: Initialize the link. Args: - x (int): The string-grid X coordinate of the link. - y (int): The string-grid Y coordinate of the link. + x (int): The xygrid x coordinate + y (int): The xygrid y coordinate. """ - self.x = x self.y = y if not self.display_symbol: self.display_symbol = self.symbol - def get_visually_connected(self, string_map, directions=None): + def get_visually_connected(self, xygrid, directions=None): """ A helper to get all directions to which there appears to be a visual link/node. This does not trace the link and check weights etc. Args: link (MapLink): Currently active link. - string_map (dict): 2D dict with x,y coordinates as keys. + xygrid (dict): 2D dict with x,y coordinates as keys. directions (list, optional): The directions (n, ne etc) to check visual connection to. @@ -292,18 +305,18 @@ class MapLink: for direction in directions: dx, dy = _MAPSCAN[direction] end_x, end_y = self.x + dx, self.y + dy - if end_x in string_map and end_y in string_map[end_x]: - links[direction] = string_map[end_x][end_y] + if end_x in xygrid and end_y in xygrid[end_x]: + links[direction] = xygrid[end_x][end_y] return links - def get_directions(self, start_direction, string_map): + def get_directions(self, start_direction, xygrid): """ Hook to override for customizing how the directions are determined. Args: start_direction (str): The starting direction (n, ne etc). - string_map (dict): 2D dict with x,y coordinates as keys. + xygrid (dict): 2D dict with x,y coordinates as keys. Returns: dict: The directions map {start_direction:end_direction} of @@ -312,13 +325,13 @@ class MapLink: """ return self.directions - def get_weights(self, start_direction, string_map, current_weight): + def get_weights(self, start_direction, xygrid, current_weight): """ Hook to override for customizing how the weights are determined. Args: start_direction (str): The starting direction (n, ne etc). - string_map (dict): 2D dict with x,y coordinates as keys. + xygrid (dict): 2D dict with x,y coordinates as keys. current_weight (int): This can have an existing value if we are progressing down a multi-step path. @@ -329,14 +342,14 @@ class MapLink: """ return self.weights - def traverse(self, start_direction, string_map, _weight=0, _linklen=1): + def traverse(self, start_direction, xygrid, _weight=0, _linklen=1): """ Recursively traverse a set of links. Args: start_direction (str): The direction (n, ne etc) from which this traversal originates for this link. - string_map (dict): 2D dict with x,y coordinates as keys. + xygrid (dict): 2D dict with x,y coordinates as keys. Kwargs: _weight (int): Internal use. _linklen (int): Internal use. @@ -349,7 +362,7 @@ class MapLink: """ # from evennia import set_trace;set_trace() - end_direction = self.get_directions(start_direction, string_map).get(start_direction) + end_direction = self.get_directions(start_direction, xygrid).get(start_direction) if not end_direction: raise MapParserError(f"Link at ({self.x}, {self.y}) was connected to " f"from {start_direction}, but does not link that way.") @@ -357,13 +370,13 @@ class MapLink: dx, dy = _MAPSCAN[end_direction] end_x, end_y = self.x + dx, self.y + dy try: - next_target = string_map[end_x][end_y] + next_target = xygrid[end_x][end_y] except KeyError: raise MapParserError(f"Link at ({self.x}, {self.y}) points to " f"empty space in direction {end_direction}!") _weight += self.get_weights( - start_direction, string_map, _weight).get( + start_direction, xygrid, _weight).get( start_direction, self.default_weight) if hasattr(next_target, "node_index"): @@ -375,7 +388,7 @@ class MapLink: # we hit another link. Progress recursively. return next_target.traverse( _REVERSE_DIRECTIONS[end_direction], - string_map, _weight=_weight, _linklen=_linklen + 1) + xygrid, _weight=_weight, _linklen=_linklen + 1) # ---------------------------------- @@ -462,10 +475,10 @@ class DynamicMapLink(MapLink): """ symbol = "o" - def get_directions(self, start_direction, string_map): + def get_directions(self, start_direction, xygrid): # get all visually connected links directions = {} - links = list(self.get_visually_connected(string_map).keys()) + links = list(self.get_visually_connected(xygrid).keys()) loop_links = links.copy() # first get all cross-through links for direction in loop_links: @@ -492,7 +505,7 @@ DEFAULT_LEGEND = { "|": NSMapLink, "-": EWMapLink, "/": NESWMapLink, - "//": SENWMapLink, + "\\": SENWMapLink, "x": CrossMapLink, "+": PlusMapLink, "v": NSOneWayMapLink, @@ -501,49 +514,53 @@ DEFAULT_LEGEND = { ">": WEOneWayMapLink, } +# -------------------------------------------- +# Map parser implementation + class Map: - """ - 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. + 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. - This is a parser that parses a string with an ascii-created map into - a 2D-array understood by 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 ... - The result of this is labeling every node in the tree with a number 0...N. + 4 # # # + | \ / + 3 #-#-# # # + | \ / + 2 #-#-# # + |x|x| | + 1 #-#-#-#-#-#-# + / + 0 #-# - The grid should be defined to be as readable as possible and every full coordinat must - be separated by a space/empty line. The single `+` in the upper left corner is used to - tell the parser where the axes cross. The (0,0) point will start one space/line away - from this point. The strict numbering is optional (the + is all that's needed), but it's - highly recommended for readability! - :: - ''' - 1 1 1 - + 0 1 2 3 4 5 6 7 8 9 0 1 2 ... + + 0 1 2 3 4 5 6 7 8 9 1 1 1 ... + 0 1 2 + ''' - 0 - - 1 - - 2 - - . - . - - 10 - - 11 - - . - . - ''' + 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 = 1000 empty_symbol = ' ' + # we normally only accept one single character for the legend key + legend_key_exceptions = ("\\") def __init__(self, map_module_or_dict): """ @@ -554,26 +571,28 @@ class Map: this should be a dict with a key 'map' and optionally a 'legend' dicts to specify the map structure. + 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 + """ - # load data from dict or file - 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) + # store so we can reload + self.map_module_or_dict = map_module_or_dict - self.mapstring = mapdata['map'] - self.legend = map_module_or_dict.get("legend", DEFAULT_LEGEND) - - self.string_map = None - self.node_map = None + # map setup + self.xygrid = None + self.XYgrid = None self.display_map = None - self.width = 0 - self.height = 0 + self.max_x = 0 + self.max_y = 0 + self.max_X = 0 + self.max_Y = 0 # Dijkstra algorithm variables self.node_index_map = None @@ -581,120 +600,30 @@ class Map: self.dist_matrix = None self.pathfinding_routes = None - self.parse() + # load data and parse it + self.reload() def __str__(self): return "\n".join("".join(line) for line in self.display_map) - def parse(self): - """ - Parse the numberical grid in the string. The result of this is a 2D array - of [[MapNode,...], [MapNode, ...]] with MapLinks inside them describing their - linkage to other nodes. - - Notes: - """ - mapcorner_symbol = self.mapcorner_symbol - # this allows for string-based [x][y] mapping with arbitrary objects - string_map = defaultdict(dict) - # needed by pathfinder - node_index_map = {} - # mapping nodes to real x,y positions - node_map = defaultdict(dict) - - mapstring = self.mapstring - if mapcorner_symbol not in mapstring: - raise MapParserError("mapstring must have a '+' in the upper left corner to mark " - "the origo of the coordinate system.") - - # find the the (xstring, ystring) position where the corner symbol is - maplines = mapstring.split("\n") - mapcorner_x, mapcorner_y = 0, 0 - for mapcorner_y, line in enumerate(maplines): - mapcorner_x = line.find(mapcorner_symbol) - if mapcorner_x != -1: - break - - # in-string_position of (x,y) - origo_x, origo_y = mapcorner_x + 2, mapcorner_y + 2 - - # we have placed the origo, start parsing the grid - - node_index = 0 - maxwidth = 0 - maxheight = 0 - - # first pass: read string-grid and parse even (x,y) coordinates into nodes - for iy, line in enumerate(maplines[origo_y:]): - even_iy = iy % 2 == 0 - for ix, char in enumerate(line[origo_x:]): - - if char == self.empty_symbol: - continue - - even_ix = ix % 2 == 0 - maxwidth = max(maxwidth, ix + 1) - maxheight = max(maxheight, iy + 1) # only increase if there's something on the line - - mapnode_or_link_class = self.legend.get(char) - if not mapnode_or_link_class: - raise MapParserError( - f"Symbol '{char}' on grid position ({ix,iy}) is not found in LEGEND.") - - if even_iy and even_ix: - # a node position will only appear on even positions in the string grid. - if hasattr(mapnode_or_link_class, "node_index"): - # this is an actual node that represents an in-game location - # - register it properly. - # the x,y stored on the node is the 'actual' xy position in the game - # world, not just the position in the string map (that is stored - # in the string_map indices instead). - realx, realy = ix // 2, iy // 2 - string_map[ix][iy] = node_map[realx][realy] = node_index_map[node_index] = \ - mapnode_or_link_class(node_index=node_index, x=realx, y=realy) - node_index += 1 - continue - - # an in-between coordinates, or on-node position link - string_map[ix][iy] = mapnode_or_link_class(x=ix, y=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.build_links(string_map) - - # build display map - display_map = [[" "] * maxwidth for _ in range(maxheight)] - for ix, ydct in string_map.items(): - for iy, node_or_link in ydct.items(): - display_map[iy][ix] = node_or_link.display_symbol - - # store - self.width = maxwidth - self.height = maxheight - self.string_map = string_map - self.node_index_map = node_index_map - self.display_map = display_map - self.node_map = node_map - - def _get_node_from_coord(self, x, y): + def _get_node_from_coord(self, X, Y): """ Get a MapNode from a coordinate. Args: - x (int): X-coordinate on game grid. - y (int): Y-coordinate on game grid. + X (int): X-coordinate on XY (game) grid. + Y (int): Y-coordinate on XY (game) grid. Returns: MapNode: The node found at the given coordinates. """ - if not self.node_map: + if not self.XYgrid: self.parse() try: - return self.node_map[x][y] + return self.XYgrid[X][Y] except IndexError: raise MapError("_get_node_from_coord got coordinate ({x},{y}) which is " "outside the grid size of (0,0) - ({self.width}, {self.height}).") @@ -719,22 +648,189 @@ class Map: pathfinding_matrix, directed=True, return_predecessors=True, limit=1000) + 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 topleft_marker_x == -1 or topleft_marker_y == -1: + 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).") + + # 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 + 2 + + # 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 xygrid position ({ix},{iy}) 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}' (xygrid ({ix},{iy}) marks a Node but is located " + "between valid (X,Y) positions!") + + # 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(x=ix, y=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.build_links(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.display_symbol + + # 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_shortest_path(self, startcoord, endcoord): """ Get the shortest route between two points on the grid. Args: - startcoord (tuple or MapNode): A starting (x,y) coordinate for where - we start from. - endcoord (tuple or MapNode): The end (x,y) coordinate we want to - find the shortest route to. + 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 one containing the shortest sequence of map nodes to traverse and the second a list of directions (n, se etc) describing the path. """ - istartnode = self._get_node_from_coord(*startcoord).node_index endnode = self._get_node_from_coord(*endcoord) @@ -760,37 +856,47 @@ class Map: return nodepath, linkpath - def get_map_region(self, x, y, dist=2, return_str=True): + def get_map_display(self, coord, dist=2, character='@', return_str=True): """ Display the map centered on a point and everything around it within a certain distance. Args: - x (int): In-world X coordinate. - y (int): In-world Y coordinate. - dist (int): Number of gridpoints distance to show. + coord (tuple): (X,Y) in-world coordinate location. + dist (int, optional): Number of gridpoints distance to show. A value of 2 will show adjacent nodes, a value - of 1 will only show links from current node. + of 1 will only show links from current node. If this is None, + show entire map centered on iX,iY. + character (str, optional): Place this symbol at the `coord` position + of the displayed map. Ignored if falsy. return_str (bool, optional): Return result as an already formatted string. Returns: str or list: Depending on value of `return_str`. If a list, - this is 2D list of lines, [[str,str,str,...], [...]] where + this is 2D grid of lines, [[str,str,str,...], [...]] where each element is a single character in the display grid. To - extract a coordinate from it, use listing[iy][ix] + extract a character at (ix,iy) coordinate from it, use + indexing `outlist[iy][ix]` in that order. """ - width, height = self.width, self.height - # convert to string-map coordinates. Remember that y grid grows downwards - ix, iy = max(0, min(x * 2, width)), max(0, min(y * 2, height)) - left, right = max(0, ix - dist), min(width, ix + dist + 1) - top, bottom = max(0, iy - dist), min(height, iy + dist + 1) - output = [] - if return_str: - for line in self.display_map[top:bottom]: - output.append("".join(line[left:right])) - return "\n".join(output) + 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)) + + if dist is None: + gridmap = self.display_map + ixc, iyc = ix, iy else: - for line in self.display_map[top:bottom]: - output.append(line[left:right]) - return output + left, right = max(0, ix - dist), min(width, ix + dist + 1) + bottom, top = max(0, iy - dist), min(height, iy + dist + 1) + ixc, iyc = ix - left, iy - bottom + gridmap = [line[left:right] for line in self.display_map[bottom:top]] + + if character: + gridmap[iyc][ixc] = character # correct indexing; it's a list of lines + + if return_str: + return "\n".join("".join(line) for line in gridmap) + else: + return gridmap diff --git a/evennia/contrib/map_and_pathfind/tests.py b/evennia/contrib/map_and_pathfind/tests.py index fdbcb2c661..fbd7515a48 100644 --- a/evennia/contrib/map_and_pathfind/tests.py +++ b/evennia/contrib/map_and_pathfind/tests.py @@ -13,10 +13,11 @@ MAP1 = """ + 0 1 2 - 0 #-# - | | 1 #-# + | | + 0 #-# + + 0 1 2 """ MAP1_DISPLAY = """ @@ -41,25 +42,38 @@ class TestMap1(TestCase): def test_node_from_coord(self): node = self.map._get_node_from_coord(1, 1) - self.assertEqual(node.x, 1) - self.assertEqual(node.y, 1) + self.assertEqual(node.X, 1) + self.assertEqual(node.x, 2) + self.assertEqual(node.X, 1) + self.assertEqual(node.y, 2) def test_get_shortest_path(self): nodepath, linkpath = self.map.get_shortest_path((0, 0), (1, 1)) self.assertEqual([node.node_index for node in nodepath], [0, 1, 3]) - self.assertEqual(linkpath, ['e', 's']) + self.assertEqual(linkpath, ['e', 'n']) @parameterized.expand([ - (0, 0, "#-\n| ", [["#", "-"], ["|", " "]]), - (1, 0, "-#\n |", [["-", "#"], [" ", "|"]]), - (0, 1, "| \n#-", [["|", " "], ["#", "-"]]), - (1, 1, " |\n-#", [[" ", "|"], ["-", "#"]]), + ((0, 0), "#-\n| ", [["#", "-"], ["|", " "]]), + ((1, 0), "-#\n |", [["-", "#"], [" ", "|"]]), + ((0, 1), "| \n#-", [["|", " "], ["#", "-"]]), + ((1, 1), " |\n-#", [[" ", "|"], ["-", "#"]]), ]) - def test_get_map_region(self, x, y, expectstr, expectlst): - string = self.map.get_map_region(x, y, dist=1) - lst = self.map.get_map_region(x, y, dist=1, return_str=False) + def test_get_map_display(self, coord, expectstr, expectlst): + string = self.map.get_map_display(coord, dist=1, character=None) + lst = self.map.get_map_display(coord, dist=1, return_str=False, character=None) self.assertEqual(string, expectstr) self.assertEqual(lst, expectlst) + @parameterized.expand([ + ((0, 0), "@-\n| ", [["@", "-"], ["|", " "]]), + ((1, 0), "-@\n |", [["-", "@"], [" ", "|"]]), + ((0, 1), "| \n@-", [["|", " "], ["@", "-"]]), + ((1, 1), " |\n-@", [[" ", "|"], ["-", "@"]]), + ]) + def test_get_map_display__character(self, coord, expectstr, expectlst): + string = self.map.get_map_display(coord, dist=1, character='@') + lst = self.map.get_map_display(coord, dist=1, return_str=False, character='@') + self.assertEqual(string, expectstr) + self.assertEqual(lst, expectlst)