diff --git a/map_and_pathfind/mapsystem.py b/map_and_pathfind/mapsystem.py new file mode 100644 index 0000000000..efbc696ae9 --- /dev/null +++ b/map_and_pathfind/mapsystem.py @@ -0,0 +1,778 @@ +r""" +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 + + MAP = r''' + 1 + + 0 1 2 3 4 5 6 7 8 9 0 + + 0 # + \ + 1 #-#-#-# + |\ | + 2 #-#-#-#-----# + | | + 3 #-#---#-#-#-#-# + | |x|x| + 4 o-#-#-# #-#-# + \ |x|x| + 5 o---#-# #-#-# + / + 6 # + \ + 7 #-#-#-# + | | + 8 #-#-#-# # + ^ + 9 #-# # + + 10 + + ''' + + LEGEND = {'#': mapsystem.MapNode, '|': mapsystem.NSMapLink,...} + + # optional, for more control + MAP_DATA = { + "map": MAP, + "legend": LEGEND, + } + +``` + +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). + + + +---- +""" +from collections import defaultdict +from scipy.sparse.csgraph import dijkstra +from scipy.sparse import csr_matrix +from scipy import zeros +from evennia.utils.utils import variable_from_module, mod_import + + +_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) +} + + +class MapError(RuntimeError): + pass + +class MapParserError(MapError): + pass + + +class MapNode: + """ + This represents a 'room' node on the map. + + A node is always located at an (int, int) location + on the map, even if it actually represents a throughput + to another node. + + """ + # symbol used in map definition + symbol = '#' + # if printing this node should show another symbol. If set + # to the empty string, use `symbol`. + display_symbol = '' + + def __init__(self, x, y, node_index): + """ + Initialize the mapnode. + + Args: + x (int): X coordinate. This is the actual room coordinate. + y (int): Y coordinate. This is the actual room coordinate. + node_index (int): This identifies this node with a running + index number required for pathfinding. + + """ + + self.x = x + self.y = y + self.node_index = node_index + + if not self.display_symbol: + self.display_symbol = self.symbol + + # this indicates linkage in 8 cardinal directions on the string-map, + # n,ne,e,se,s,sw,w,nw and link that to a node (always) + self.links = {} + # this maps + self.weights = {} + # lowest direction to a given neighbor + self.cheapest_to_node = {} + + def build_links(self, string_map): + """ + 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. + + """ + # convert room-coordinates back to string-map coordinates + x, y = self.x * 2, self.y * 2 + + # scan in all directions for links + for direction, (dx, dy) in _MAPSCAN.items(): + + # note that this is using the string-coordinate system, not the room-one, + # - there are two string coordinates (node + link) per room coordinate + # 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] + # 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(self, _REVERSE_DIRECTIONS(direction), string_map) + if end_node: + # the link could be followed to an end node! + self.links[direction] = end_node + self.weights[direction] = weight + node_index = end_node.node_index + + if weight < self.cheapest_to_node.get(node_index, _BIG): + self.cheapest_to_node[node_index] = direction + + def linkweights(self, nnodes): + """ + Retrieve all the weights for the direct links to all other nodes. + + Args: + nnodes (int): The total number of nodes + + Returns: + scipy.array: Array of weights of the direct links to other nodes. + The weight will be 0 for nodes not directly connected to one another. + + Notes: + A node can at most have 8 connections (the cardinal directions). + + """ + link_graph = zeros(nnodes) + for node_index, weight in self.weights.items(): + link_graph[node_index] = weight + return link_graph + + def get_cheapest_link_to(self, node): + """ + Get the cheapest path to a node (there may be several possible). + + Args: + node (MapNode): The node to get to. + + Returns: + str: The direction (nw, se etc) to get to that node in the cheapest way. + + """ + return self.cheapest_to_node[node.node_index] + + +class MapLink: + """ + This represents a link between up to 8 nodes. A link is always + located on a (.5, .5) location on the map like (1.5, 2.5). + + Each link has a 'weight' from 1...inf, whis indicates how 'slow' + it is to traverse that link. This is used by the Dijkstra algorithm + to find the 'fastest' route to a point. By default this weight is 1 + for every link, but a locked door, terrain etc could increase this + and have the algorithm prefer to use another route. + + It is usually bidirectional, but could also be one-directional. + It is also possible for a link to have some sort of blockage, like + a door. + + """ + # link setup + symbol = "|" + display_symbol = "" + # this indicates linkage start:end in 8 cardinal directions on the string-map, + # n,ne,e,se,s,sw,w,nw. A link is described as {startpos:endpoit}, like connecting + # the named corners with a line. If the inverse direction is also possible, it + # must also be specified. So a south-northward, two-way link would be described + # as {"s": "n", "n": "s"}. The get_directions method can be customized to + # dynamically modify this during parsing. + directions = {} + # this is required for pathfinding. 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_directions method can be + # customized to modify this during parsing. + weights = {} + default_weight = 1 + # This setting only applies if this is the *first* link in a chain of multiple links. Usually, + # when multiple links are used to tie together two nodes, the default is to average the weight + # across all links. With this disabled, the weights will be added and a long link will be + # considered 'longer' by the pathfinder. + average_long_link_weights = True + + def __init__(self, x, y): + """ + Initialize the link. + + Args: + x (int): The string-grid X coordinate of the link. + y (int): The string-grid Y coordinate of the link. + + """ + + 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): + """ + 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. + directions (list, optional): The directions (n, ne etc) to check + visual connection to. + + Returns: + dict: Mapping {direction: node_or_link} wherever such was found. + + """ + if not directions: + directions = _REVERSE_DIRECTIONS + links = {} + 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] + return links + + def get_directions(self, start_direction, string_map): + """ + 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. + + Returns: + dict: The directions map {start_direction:end_direction} of + the link. By default this is just self.directions. + + """ + return self.directions + + def get_weights(self, start_direction, string_map, 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. + current_weight (int): This can have an existing value if + we are progressing down a multi-step path. + + Returns: + dict: The directions map {start_direction:weight} of + the link. By default this is just self.weights + + """ + return self.weights + + def traverse(self, start_direction, string_map, _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. + Kwargs: + _weight (int): Internal use. + _linklen (int): Internal use. + + Returns: + tuple: The (node, weight) result of the traversal. + + Raises: + MapParserError: If a link lead to nowhere. + + """ + end_direction = self.get_directions(start_direction, string_map).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.") + + dx, dy = _MAPSCAN[end_direction] + end_x, end_y = self.x + dx, self.y + dy + try: + next_target = string_map[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, self.default_weight) + + if hasattr(next_target, "node_index"): + # we reached a node, this is the end of the link. + # we average the weight across all traversed link segments. + return next_target, ( + _weight / max(1, _linklen) if self.average_long_link_weights else _weight) + else: + # we hit another link. Progress recursively. + return next_target.traverse( + _REVERSE_DIRECTIONS(end_direction), + string_map, _weight=_weight, _linklen=_linklen + 1) + + +# ---------------------------------- +# Default nodes and link classes + +class NSMapLink(MapLink): + symbol = "|" + directions = {"s": "n", "n": "s"} + + +class EWMapLink(MapLink): + symbol = "-" + directions = {"e": "w", "w": "e"} + + +class NESWMapLink(MapLink): + symbol = "/" + directions = {"ne": "sw", "sw": "ne"} + + +class SENWMapLink(MapLink): + symbol = "\\" + directions = {"se": "nw", "nw": "se"} + + +class CrossMapLink(MapLink): + symbol = "x" + directions = {"ne": "sw", "sw": "ne", + "se": "nw", "nw": "se"} + + +class PlusMapLink(MapLink): + symbol = "+" + directions = {"s": "n", "n": "s", + "e": "w", "w": "e"} + + +class NSOneWayMapLink(MapLink): + symbol = "v" + directions = {"n": "s"} + + +class SNOneWayMapLink(MapLink): + symbol = "^" + directions = {"s": "n"} + + +class EWOneWayMapLink(MapLink): + symbol = "<" + directions = {"e": "w"} + + +class WEOneWayMapLink(MapLink): + symbol = ">" + directions = {"w": "e"} + + +class DynamicMapLink(MapLink): + r""" + This can be used both on a node position and link position but does not represent a location + in-game, but is only intended to help link things together. The dynamic link has no visual + direction so we parse the visual surroundings in the map to see if it's obvious what is + connected to what. If there are links on carinally opposite sites, these are considered + pass-throughs. If determining this is not possible, or there is an uneven number of links, an + error is raised. + :: + / + -o - this is ok, there can only be one path + + | + -o- - this will be assumed to be two links + | + + \|/ + -o- - all are passing straight through + /|\ + + -o- - w-e pass, other is sw-s + /| + + -o - invalid + /| + + """ + symbol = "o" + + def get_directions(self, start_direction, string_map): + # get all visually connected links + directions = {} + links = list(self.get_visually_connected(string_map).keys()) + loop_links = links.copy() + # first get all cross-through links + for direction in loop_links: + if _REVERSE_DIRECTIONS[direction] in loop_links: + directions[direction] = links.pop(direction) + + # check if we have any non-cross-through paths to handle + if len(links) != 2: + links = "-".join(links) + raise MapParserError( + f"dynamic link at grid ({self.x, self.y}) cannot determine " + f"where how to connect links leading to/from {links}.") + directions[links[0]] = links[1] + directions[links[1]] = links[0] + + return directions + + +# these are all symbols used for x,y coordinate spots +# at (0,1) etc. +DEFAULT_LEGEND = { + "#": MapNode, + "o": MapLink, + "|": NSMapLink, + "-": EWMapLink, + "/": NESWMapLink, + "//": SENWMapLink, + "x": CrossMapLink, + "+": PlusMapLink, + "v": NSOneWayMapLink, + "^": SNOneWayMapLink, + "<": EWOneWayMapLink, + ">": WEOneWayMapLink, +} + + +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. + + This is a parser that parses a string with an ascii-created map into + a 2D-array understood by the Dijkstra algorithm. + + The result of this is labeling every node in the tree with a number 0...N. + + 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 + + . + . + + 10 + + 11 + + . + . + ''' + + """ + mapcorner_symbol = '+' + max_pathfinding_length = 1000 + empty_symbol = ' ' + + def __init__(self, map_module_or_dict): + """ + 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 'room_legend' and + 'link_legend' dicts to specify the map structure. + + """ + # 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) + + self.mapstring = mapdata['map'] + self.node_legend = map_module_or_dict.get("legend", DEFAULT_LEGEND) + + self.string_map = None + self.node_map = None + self.display_map = None + self.width = 0 + self.height = 0 + + # Dijkstra algorithm variables + self.node_index_map = None + self.pathfinding_matrix = None + self.dist_matrix = None + self.pathfinding_routes = None + + self.parse() + + def __str__(self): + return "\n".join(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 [x][y] mapping with arbitrary objects + string_map = defaultdict(dict) + # needed by pathfinder + node_index_map = {} + + 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 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:]): + maxheight = max(maxheight, iy) + 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) + + if even_iy and even_ix: + # a node position will only appear on even positions in the string grid. + + mapnode_class = self.node_legend.get(char) + if not mapnode_class: + raise MapParserError(f"Node symbol '{char}' not in NODE_LEGEND.") + + if hasattr(mapnode_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). + string_map[ix][iy] = self.node_map[ix][iy] = node_index_map[node_index] = \ + mapnode_class(node_index=node_index, x=ix // 2, y=iy // 2) + node_index += 1 + continue + else: + # we have a link at a coordinate position. Store it but don't add + # it to the node_index_map since it doesn't have an in-game existence. + string_map[ix][iy] = mapnode_class() # actually a linknode class + else: + # an in-between coordinates link + linknode_class = self.link_legend(char) + if not linknode_class: + raise MapParserError(f"Link symbol '{char}' not in LINK_LEGEND.") + string_map[ix][iy] = linknode_class() + + # 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: + 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 = self.node_index_map + self.display_map = display_map + + def _get_node_from_coord(self, x, y): + """ + Get a MapNode from a coordinate. + + Args: + x (int): X-coordinate on grid. + y (int): Y-coordinate on grid. + + """ + if not self.node_2d_map: + self.parse() + + try: + self.node_2d_map[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}).") + + def _calculate_path_matrix(self): + """ + Solve the pathfinding problem using Dijkstra's algorithm. + + """ + nnodes = len(self.node_index_map) + + pathfinding_graph = zeros((nnodes, nnodes)) + # build a matrix representing the map graph, with 0s as impassable areas + 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=1000) + + 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. + + 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(*endcoord).node_index + endnode = self._get_node_from_coord(*startcoord) + + if not self.pathfinding_routes: + self._calculate_path_matrix() + + pathfinding_routes = self.pathfinding_routes + node_index_map = self.node_index_map + + nodepath = [endnode] + linkpath = [] + inextnode = endnode.node_index + + while pathfinding_routes[istartnode, inextnode] != -9999: + # the -9999 is set by algorithm for unreachable nodes or end-node + inextnode = pathfinding_routes[istartnode, inextnode] + nodepath.append(node_index_map[inextnode]) + linkpath.append(nodepath[-1].get_cheapest_link_to(nodepath[-2])) + + # we have the path - reverse to get the correct order + nodepath = nodepath[::-1] + linkpath = linkpath[::-1] + + return nodepath, linkpath + + def get_map_region(self, x, y, dist=2): + """ + 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. + A value of 2 will show adjacent nodes, a value + of 1 will only show links from current node. + + """ + width, height = self.width, self.height + # convert to string-map coordinates + ix, iy = max(0, min(x * 2, width)), max(0, min(y * 2, height)) + left, right = max(0, ix - dist), min(width, ix + dist) + top, bottom = max(0, iy - dist), min(height, iy + dist) + + output = [] + for line in self.display_map[top:bottom]: + output.append(line[left:right]) + return "\n".join(output)