diff --git a/evennia/contrib/map_and_pathfind/mapsystem.py b/evennia/contrib/map_and_pathfind/mapsystem.py index dd19cffc0c..ad15a324be 100644 --- a/evennia/contrib/map_and_pathfind/mapsystem.py +++ b/evennia/contrib/map_and_pathfind/mapsystem.py @@ -175,6 +175,9 @@ class MapNode: def __str__(self): return f"" + def __repr__(self): + return str(self) + def build_links(self, xygrid): """ Start tracking links in all cardinal directions to tie this to another node. All @@ -303,6 +306,9 @@ class MapLink: def __str__(self): return f"" + def __repr__(self): + return str(self) + def get_visually_connected(self, xygrid, directions=None): """ A helper to get all directions to which there appears to be a @@ -831,27 +837,33 @@ class Map: # process the new(?) data self._parse() - def get_node_from_coord(self, X, Y): + def get_node_from_coord(self, coords): """ Get a MapNode from a coordinate. Args: - X (int): X-coordinate on XY (game) grid. - Y (int): Y-coordinate on XY (game) grid. + coords (tuple): X,Y coordinates on XYgrid. Returns: - MapNode: The node found at the given coordinates. + 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() - try: - return self.XYgrid[X][Y] - except IndexError: - raise MapError("get_node_from_coord got coordinate ({x},{y}) which is " + iX, iY = coords + if not ((0 <= iX <= self.max_X) and (0 <= iY <= self.max_Y)): + raise MapError("get_node_from_coord got coordinate {coords} which is " "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): """ @@ -869,10 +881,10 @@ class Map: the full path including the start- and end-node. """ - startnode = self.get_node_from_coord(*startcoord) - endnode = self.get_node_from_coord(*endcoord) + startnode = self.get_node_from_coord(startcoord) + endnode = self.get_node_from_coord(endcoord) - if not self.pathfinding_routes: + if self.pathfinding_routes is None: self._calculate_path_matrix() pathfinding_routes = self.pathfinding_routes @@ -898,7 +910,7 @@ class Map: return directions, path - def get_map_display(self, coord, dist=2, only_nodes=False, + def get_map_display(self, coord, dist=2, mode='scan', character='@', max_size=None, return_str=True): """ Display the map centered on a point and everything around it within a certain distance. @@ -907,16 +919,14 @@ class Map: coord (tuple): (X,Y) in-world coordinate location. dist (int, optional): Number of gridpoints distance to show. Which grid to use depends on the setting of `only_nodes`. - only_nodes (boolean): This determins if `dist` only counts the number of - full nodes or counts the number of actual visual map-grid-points - (including links). If set, it's recommended to set `max_size` to avoid - too-large map displays. + mode (str, optional): One of 'scan' or 'nodes'. In 'scan' mode, dist measure + number of xy grid points in all directions. If 'nodes', distance + measure how many full nodes away to display. character (str, optional): Place this symbol at the `coord` position of the displayed map. Ignored if falsy. - max_size (tuple, optional): A max `(width, height)` of the resulting - string or list. This can be useful together with `only_nodes` - to avoid a map display growing unexpectedly. If unset, size - can grow up to the full size of the map. + 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. @@ -962,48 +972,77 @@ class Map: # 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 - if only_nodes: - # dist measures only full, reachable nodes + if dist <= 0: + # show nothing but ourselves + return character if character else ' ' - # we will build a list of coordinates (from the full - # map display) to actually include in the final + if mode == 'nodes': + # dist measures only full, reachable nodes. + # this requires a series of shortest-path + # Steps from on the pre-calulcated grid. + + if not self.dist_matrix: + self._calculate_path_matrix() + + xmin, ymin = width, height + xmax, ymax = 0, 0 + # adjusted center of map section + ixc, iyc = ix, iy + + center_node = self.get_node_from_coord((iX, iY)) + if not center_node: + # there is nothing at this grid location + return character if character else ' ' + + # the points list coordinates on the xygrid to show. points = [(ix, iy)] - xmax = 0 - ymax = 0 - node_index_map = self.node_index_map - center_node = self.get_node_from_coord(iX, iY) # find all reachable nodes within a (weighted) distance of `dist` for inode, node_dist in enumerate(self.dist_matrix[center_node.node_index]): + if node_dist > dist: continue + # we have a node within 'dist' from us, get, the route to it node = node_index_map[inode] - _, path = self.get_shortest_path(node.iX, node.iY) + _, path = self.get_shortest_path((iX, iY), (node.X, node.Y)) # follow directions to figure out which map coords to display node0 = node ix0, iy0 = ix, iy for path_element in path: + # we don't need the start node since we know it already if isinstance(path_element, str): # a direction - this can lead to following # a longer link-chain chain for dstep in node0.xy_steps_in_direction[path_element]: dx, dy = _MAPSCAN[dstep] ix0, iy0 = ix0 + dx, iy0 + dy - xmax, ymax = max(xmax, ix0), max(ymax, iy0) points.append((ix0, iy0)) + xmin, ymin = min(xmin, ix0), min(ymin, iy0) + xmax, ymax = max(xmax, ix0), max(ymax, iy0) else: # a Mapnode node0 = path_element - ix0, iy0 = node0.ix, node0.iy - points.append((ix0, iy0)) - + ix0, iy0 = node0.x, node0.y + if (ix0, iy0) != (ix, iy): + points.append((ix0, iy0)) + xmin, ymin = min(xmin, ix0), min(ymin, iy0) + xmax, ymax = max(xmax, ix0), max(ymax, iy0) + # from evennia import set_trace;set_trace() + 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] else: - # dist measures individual grid points + # scan-mode (default) - dist measures individual grid points if dist is None: gridmap = self.display_map ixc, iyc = ix, iy @@ -1011,13 +1050,20 @@ class Map: 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]] + gridmap = [line[left:right] for line in display_map[bottom:top]] - if character: - gridmap[iyc][ixc] = character # correct indexing; it's a list of lines + if character: + gridmap[iyc][ixc] = character # correct indexing; it's a list of lines + + if max_size: + # crop grid to make sure it doesn't grow too far + max_x, max_y = max_size + left, right = max(0, ixc - max_x // 2), min(width, ixc + max_x // 2 + 1) + bottom, top = max(0, iyc - max_y // 2), min(height, iyc + max_y // 2 + 1) + gridmap = [line[left:right] for line in gridmap[bottom:top]] - # we must flip the y-axis before returning 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/map_and_pathfind/tests.py index c478f86a3a..5eca00d677 100644 --- a/evennia/contrib/map_and_pathfind/tests.py +++ b/evennia/contrib/map_and_pathfind/tests.py @@ -77,7 +77,7 @@ class TestMap1(TestCase): self.assertEqual(str(self.map).strip(), MAP1_DISPLAY) def test_node_from_coord(self): - node = self.map.get_node_from_coord(1, 1) + node = self.map.get_node_from_coord((1, 1)) self.assertEqual(node.X, 1) self.assertEqual(node.x, 2) self.assertEqual(node.X, 1) @@ -130,6 +130,20 @@ class TestMap1(TestCase): self.assertEqual(expectstr, mapstr) self.assertEqual(expectlst, maplst[::-1]) # flip y-axis to match print direction + @parameterized.expand([ + ((0, 0), '# \n| \n@-#'), + ((0, 1), '@-#\n| \n# '), + ((1, 0), ' #\n |\n#-@'), + ((1, 1), '#-@\n |\n #'), + + ]) + def test_get_map_display__nodes__character(self, coord, expected): + """ + Get sub-part of map with node-mode. + + """ + mapstr = self.map.get_map_display(coord, dist=1, mode='nodes', character='@') + self.assertEqual(expected, mapstr) class TestMap2(TestCase): """ @@ -148,7 +162,7 @@ class TestMap2(TestCase): def test_node_from_coord(self): for mapnode in self.map.node_index_map.values(): - node = self.map.get_node_from_coord(mapnode.X, mapnode.Y) + node = self.map.get_node_from_coord((mapnode.X, mapnode.Y)) self.assertEqual(node, mapnode) self.assertEqual(node.x // 2, node.X) self.assertEqual(node.y // 2, node.Y) @@ -177,7 +191,7 @@ class TestMap2(TestCase): ((4, 5), '#-#-@ \n| | \n#---# \n| | \n| #-#'), ((5, 2), '--# \n | \n #-#\n |\n#---@\n \n--#-#\n | \n#-# '), ]) - def test_get_map_display__character(self, coord, expected): + def test_get_map_display__scan__character(self, coord, expected): """ Test showing smaller part of grid, showing @-character in the middle. @@ -186,7 +200,11 @@ class TestMap2(TestCase): self.assertEqual(expected, mapstr) def test_extended_path_tracking__horizontal(self): - node = self.map.get_node_from_coord(4, 1) + """ + Crossing multi-gridpoint links should be tracked properly. + + """ + node = self.map.get_node_from_coord((4, 1)) self.assertEqual( node.xy_steps_in_direction, {'e': ['e'], @@ -195,7 +213,11 @@ class TestMap2(TestCase): ) def test_extended_path_tracking__vertical(self): - node = self.map.get_node_from_coord(2, 2) + """ + Testing multi-gridpoint links in the vertical direction. + + """ + node = self.map.get_node_from_coord((2, 2)) self.assertEqual( node.xy_steps_in_direction, {'n': ['n', 'n', 'n'], @@ -204,3 +226,28 @@ class TestMap2(TestCase): 'w': ['w']} ) + @parameterized.expand([ + ((0, 0), 2, None, '@'), # outside of any known node + ((4, 5), 0, None, '@'), # 0 distance + ((1, 0), 2, None, + '#-#-# \n | \n @-#-#'), + ((0, 5), 1, None, '@-#'), + ((0, 5), 4, None, + '@-#-#-#-#\n | \n #---#\n | \n | \n | \n # '), + ((5, 1), 3, None, ' # \n | \n#-#---#-@\n | \n #-# '), + ((2, 2), 2, None, + ' # \n | \n #---# \n | \n | \n | \n' + '#-#-@-#---#\n | \n #-#---# '), + ((2, 2), 2, (5, 5), # limit display size + ' | \n | \n#-@-#\n | \n#-#--'), + ((2, 2), 4, (3, 3), ' | \n-@-\n | '), + ((2, 2), 4, (1, 1), '@') + ]) + def test_get_map_display__nodes__character(self, coord, dist, max_size, expected): + """ + Get sub-part of map with node-mode. + + """ + mapstr = self.map.get_map_display(coord, dist=dist, mode='nodes', character='@', + max_size=max_size) + self.assertEqual(expected, mapstr)