From bab2f962f5faea26d07d3ce38c190adf40fd9fdf Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 20 Jun 2021 23:13:03 +0200 Subject: [PATCH] Start creating top-level grid class --- evennia/contrib/xyzgrid/README.md | 2 +- evennia/contrib/xyzgrid/grid.py | 20 --- evennia/contrib/xyzgrid/map_legend.py | 158 +++++++++++++++--- evennia/contrib/xyzgrid/map_multi.py | 30 ---- evennia/contrib/xyzgrid/tests.py | 44 +++-- evennia/contrib/xyzgrid/utils.py | 6 + .../xyzgrid/{map_single.py => xymap.py} | 137 +++++++++------ evennia/contrib/xyzgrid/xyzgrid.py | 146 ++++++++++++++++ 8 files changed, 405 insertions(+), 138 deletions(-) delete mode 100644 evennia/contrib/xyzgrid/grid.py delete mode 100644 evennia/contrib/xyzgrid/map_multi.py rename evennia/contrib/xyzgrid/{map_single.py => xymap.py} (90%) create mode 100644 evennia/contrib/xyzgrid/xyzgrid.py diff --git a/evennia/contrib/xyzgrid/README.md b/evennia/contrib/xyzgrid/README.md index 0686772eaf..b291d70d8a 100644 --- a/evennia/contrib/xyzgrid/README.md +++ b/evennia/contrib/xyzgrid/README.md @@ -37,7 +37,7 @@ advanced tools like pathfinding will only operate within each XY `Map`. map/location/Z-coordinate. 2. The Map Legend - describes how to parse each symbol in the map string to a topological relation, such as 'a room' or 'a two-way link east-west'. -3. The Map - combines the Map String and Legend into a parsed object with +3. The XYMap - combines the Map String and Legend into a parsed object with pathfinding and visual-range handling. 4. The MultiMap - tracks multiple maps 5. Rooms, Exits and Prototypes - custom Typeclasses that understands XYZ coordinates. diff --git a/evennia/contrib/xyzgrid/grid.py b/evennia/contrib/xyzgrid/grid.py deleted file mode 100644 index 8c3d6d9983..0000000000 --- a/evennia/contrib/xyzgrid/grid.py +++ /dev/null @@ -1,20 +0,0 @@ -""" -The grid - -This represents the full XYZ grid, which consists of - -- 2D `Map`-objects parsed from Map strings and Map-legend components. Each represents one - Z-coordinate or location. -- `Prototypes` for how to build each XYZ component into 'real' rooms and exits. -- Actual in-game rooms and exits, mapped to the game based on Map data. - -The grid has three main functions: -- Building new rooms/exits from scratch based on one or more Maps. -- Updating the rooms/exits tied to an existing Map when the Map string - of that map changes. - - -""" - -class XYZGrid: - pass diff --git a/evennia/contrib/xyzgrid/map_legend.py b/evennia/contrib/xyzgrid/map_legend.py index 123ea5cfea..5e4ea7d074 100644 --- a/evennia/contrib/xyzgrid/map_legend.py +++ b/evennia/contrib/xyzgrid/map_legend.py @@ -14,8 +14,12 @@ except ImportError as err: f"{err}\nThe XYZgrid contrib requires " "the SciPy package. Install with `pip install scipy'.") +from evennia.prototypes import spawner from .utils import MAPSCAN, REVERSE_DIRECTIONS, MapParserError, BIGVAL +NodeTypeclass = None +ExitTypeclass = None + # Nodes/Links @@ -26,6 +30,8 @@ class MapNode: the even-integer coordinates and also represents in-game coordinates/rooms. MapNodes are always located on even X,Y coordinates on the map grid and in-game. + MapNodes will also handle the syncing of themselves and all outgoing links to the grid. + Attributes on the node class: - `symbol` (str) - The character to parse from the map into this node. By default this @@ -41,6 +47,8 @@ class MapNode: is useful for marking 'points of interest' along a route, or places where you are not expected to be able to continue without some further in-game action not covered by the map (such as a guard or locked gate etc). + - `prototype` (dict) - The default `prototype` dict to use for reproducing this map component + on the game grid. This is used if not overridden specifically for this coordinate. """ # symbol used to identify this link on the map @@ -57,8 +65,10 @@ class MapNode: # this will interrupt a shortest-path step (useful for 'points' of interest, stop before # a door etc). interrupt_path = False + # the prototype to use for mapping this to the grid. + prototype = None - def __init__(self, x, y, node_index=0): + def __init__(self, x, y, node_index=0, xymap=None): """ Initialize the mapnode. @@ -68,12 +78,16 @@ class MapNode: node_index (int): This identifies this node with a running index number required for pathfinding. This is used internally and should not be set manually. + xymap (XYMap, optional): The map object this sits on. """ self.x = x self.y = y + # map name, usually + self.xymap = xymap + # XYgrid coordinate self.X = x // 2 self.Y = y // 2 @@ -83,13 +97,15 @@ class MapNode: # 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 = {} + # first MapLink in each direction - used by grid syncing + self.first_links = {} # this maps self.weights = {} # lowest direction to a given neighbor self.shortest_route_to_node = {} # maps the directions (on the xygrid NOT on XYgrid!) taken if stepping # out from this node in a given direction until you get to the end node. - # This catches eventual longer link chains that would otherwise be lost + # This catches eventual longer link chains that would otherwise be lost # {startdirection: [direction, ...], ...} # where the directional path-lists also include the start-direction self.xy_steps_to_node = {} @@ -136,6 +152,8 @@ class MapNode: if end_node: # the link could be followed to an end node! + self.first_links[direction] = link + # check the actual direction-alias to use, since this may be # different than the xygrid cardinal directions. There must be # no duplicates out of this node or there will be a @@ -183,12 +201,13 @@ class MapNode: link_graph[node_index] = weight return link_graph - def get_display_symbol(self, xygrid, **kwargs): + def get_display_symbol(self, xygrid, xymap=None, **kwargs): """ Hook to override for customizing how the display_symbol is determined. Args: xygrid (dict): 2D dict with x,y coordinates as keys. + xymap (XYMap): Main Map object. Returns: str: The display-symbol to use. This must visually be a single character @@ -200,6 +219,72 @@ class MapNode: """ return self.symbol if self.display_symbol is None else self.display_symbol + def sync_node_to_grid(self): + """ + This should be called as part of the node-sync step of the map sync. The reason is + that the exits (next step) requires all nodes to exist before they can link up + to their destinations. + + """ + global NodeTypeclass + if not NodeTypeclass: + from .room import XYZRoom as NodeTypeclass + + coord = (self.X, self.Y, self.xymap.name) + + try: + nodeobj = NodeTypeclass.objects.get_xyz(coord=coord) + except NodeTypeclass.DoesNotExist: + # create a new entity with proper coordinates etc + nodeobj = NodeTypeclass.create( + self.prototype.get('key', 'An Empty room'), + coord=coord + ) + # apply prototype to node. This will not override the XYZ tags since + # these are not in the prototype and exact=False + spawner.batch_update_objects_with_prototype( + self.prototype, objects=[nodeobj], exact=False) + + def sync_links_to_grid(self): + """ + This should be called after all `sync_node_to_grid` operations have finished across + the entire XYZgrid. This creates/syncs all exits to their locations and destinations. + + """ + coord = (self.X, self.Y, self.xymap.name) + + global ExitTypeclass + if not ExitTypeclass: + from .room import XYZExit as ExitTypeclass + + maplinks = self.first_links + # we need to search for exits in all directions since some + # may have been removed since last sync + linkobjs = {exi.db_key: exi for exi in ExitTypeclass.filter_xyz(coord=coord)} + + # figure out if the topology changed between grid and map (will always + # build all exits first run) + differing_directions = set(maplinks.keys()).symmetric_difference(set(linkobjs.keys())) + for direction in differing_directions: + if direction in linkobjs: + # an exit without a maplink - delete the exit + linkobjs.pop(direction).delete() + else: + # a maplink without an exit - create the exit + + link = maplinks[direction] + exitnode = self.links[direction] + + linkobjs[direction] = ExitTypeclass.create( + link.prototype.get('key', direction), + coord=coord, + destination_coord=(exitnode.X, exitnode.Y, exitnode.xymap.name) + ) + # apply prototypes to catch any changes + for direction, linkobj in linkobjs: + spawner.batch_update_objects_with_prototype( + maplinks[direction].prototype, objects=[linkobj], exact=False) + class MapLink: """ @@ -249,6 +334,10 @@ class MapLink: setting is necessary to avoid infinite loops when such multilinks are next to each other. - `interrupt_path` (bool): If set, a shortest-path solution will include this link as normal, but will stop short of actually moving past this link. + - `prototype` (dict) - The default `prototype` dict to use for reproducing this map component + on the game grid. This is only relevant for the *first* link out of a Node (the continuation + of the link is only used to determine its destination). This can be overridden on a + per-direction basis. """ # symbol for identifying this link on the map @@ -284,19 +373,26 @@ class MapLink: # this link does not block/reroute pathfinding, but makes the actual path always stop when # trying to cross it. interrupt_path = False + # prototype for the first link out of a node. + prototype = None + # only traverse this after all of the grid is complete + delay_traversal = False - def __init__(self, x, y): + def __init__(self, x, y, xymap=None): """ Initialize the link. Args: x (int): The xygrid x coordinate y (int): The xygrid y coordinate. + xymap (XYMap, optional): The map object this sits on. """ self.x = x self.y = y + self.xymap = xymap + self.X = x / 2 self.Y = y / 2 @@ -351,6 +447,11 @@ class MapLink: raise MapParserError( f"points to empty space in the direction {end_direction}!", self) + if next_target.xymap.name != self.xymap.name: + # this target is on another map. Immediately exit the traversal + # and set a high weight. + return (next_target, BIGVAL, [start_direction]) + _weight += self.get_weight(start_direction, xygrid, _weight) if _steps is None: _steps = [] @@ -457,16 +558,14 @@ class MapLink: """ return self.weights.get(start_direction, self.default_weight) - def get_display_symbol(self, xygrid, **kwargs): + def get_display_symbol(self, xygrid, xymap=None, **kwargs): """ Hook to override for customizing how the display_symbol is determined. This is called after all other hooks, at map visualization. Args: xygrid (dict): 2D dict with x,y coordinates as keys. - - Kwargs: - mapinstance (Map): The current Map instance. + xymap (XYMap): The map object this sits on. Returns: str: The display-symbol to use. This must visually be a single character @@ -576,8 +675,8 @@ class TeleporterMapLink(MapLink): display_symbol = ' ' direction_name = 'teleport' - def __init__(self, *args): - super().__init__(*args) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) self.paired_teleporter = None def at_empty_target(self, start_direction, xygrid): @@ -690,13 +789,11 @@ class MapTransitionLink(TeleporterMapLink): direction_name = 'transition' interrupt_path = True - map1_name = 'map' - map2_name = 'map' + target_map = 'map2' - def __init__(self, *args): - super().__init__(*args) - self.map1 = None - self.map2 = None + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.paired_map_link = None def at_empty_target(self, start_direction, end_direction, xygrid): """ @@ -709,7 +806,26 @@ class MapTransitionLink(TeleporterMapLink): xygrid (dict): 2D dict with x,y coordinates as keys. """ - # TODO - this needs some higher-level handler to work. + if not self.paired_map_link: + grid = self.xymap.grid.grid + try: + target_map = grid[self.target_map] + except KeyError: + raise MapParserError(f"cannot find target_map '{self.target_map}' " + f"on the grid.", self) + + # find the matching link on the other side + link = target_map.get_components_with_symbol(self.symbol) + if not link: + raise MapParserError(f"must have a matching '{self.symbol}' on " + f"its target_map `{self.target_map}`.", self) + if len(link) > 1: + raise MapParserError(f"must have a singl mathing '{self.symbol}' on " + f"its target_map (found {len(link)}): {link}") + # this is a link on another map + self.paired_map_link = link[0] + + return self.paired_map_link class SmartMapLink(MapLink): @@ -826,7 +942,7 @@ class InvisibleSmartMapLink(SmartMapLink): (('ne', 'sw'), ('sw', 'ne')): '/', } - def get_display_symbol(self, xygrid, **kwargs): + def get_display_symbol(self, xygrid, xymap=None, **kwargs): """ The SmartMapLink already calculated the directions before this, so we just need to figure out what to replace this with in order to make this 'invisible' @@ -836,9 +952,7 @@ class InvisibleSmartMapLink(SmartMapLink): """ if not hasattr(self, "_cached_display_symbol"): - mapinstance = kwargs['mapinstance'] - - legend = mapinstance.legend + legend = xymap.legend default_symbol = ( self.symbol if self.display_symbol is None else self.display_symbol) self._cached_display_symbol = default_symbol @@ -854,7 +968,7 @@ class InvisibleSmartMapLink(SmartMapLink): # initiate class in the current location and run get_display_symbol # to get what it would show. self._cached_display_symbol = node_or_link_class( - self.x, self.y).get_display_symbol(xygrid, **kwargs) + self.x, self.y).get_display_symbol(xygrid, xymap=xymap, **kwargs) return self._cached_display_symbol diff --git a/evennia/contrib/xyzgrid/map_multi.py b/evennia/contrib/xyzgrid/map_multi.py deleted file mode 100644 index 73d05c73d9..0000000000 --- a/evennia/contrib/xyzgrid/map_multi.py +++ /dev/null @@ -1,30 +0,0 @@ -""" -Over-arching map system for representing a larger number of Maps linked together with transitions. - -""" -from .map_single import SingleMap - - -class MultiMap: - """ - Coordinate multiple maps. - - """ - - def __init__(self): - self.maps = {} - - def add_map(self, map_module_or_dict, name="map"): - """ - Add a new map to the multimap store. - - 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', 'name' and - `prototypes` keys. - name (str): 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. - - """ - self.maps[name] = SingleMap(map_module_or_dict, name=name, other_maps=self.maps) diff --git a/evennia/contrib/xyzgrid/tests.py b/evennia/contrib/xyzgrid/tests.py index 5e7df76700..2732ee182a 100644 --- a/evennia/contrib/xyzgrid/tests.py +++ b/evennia/contrib/xyzgrid/tests.py @@ -8,7 +8,7 @@ from time import time from random import randint from unittest import TestCase from parameterized import parameterized -from . import map_single +from . import xymap MAP1 = """ @@ -320,7 +320,8 @@ class TestMap1(TestCase): """ def setUp(self): - self.map = map_single.SingleMap({"map": MAP1}, name="testmap") + self.map = xymap.XYMap({"map": MAP1}, name="testmap") + self.map.parse() def test_str_output(self): """Check the display_map""" @@ -404,7 +405,8 @@ class TestMap2(TestCase): """ def setUp(self): - self.map = map_single.SingleMap({"map": MAP2}, name="testmap") + self.map = xymap.XYMap({"map": MAP2}, name="testmap") + self.map.parse() def test_str_output(self): """Check the display_map""" @@ -514,7 +516,8 @@ class TestMap3(TestCase): """ def setUp(self): - self.map = map_single.SingleMap({"map": MAP3}, name="testmap") + self.map = xymap.XYMap({"map": MAP3}, name="testmap") + self.map.parse() def test_str_output(self): """Check the display_map""" @@ -563,7 +566,8 @@ class TestMap4(TestCase): """ def setUp(self): - self.map = map_single.SingleMap({"map": MAP4}, name="testmap") + self.map = xymap.XYMap({"map": MAP4}, name="testmap") + self.map.parse() def test_str_output(self): """Check the display_map""" @@ -593,7 +597,8 @@ class TestMap5(TestCase): """ def setUp(self): - self.map = map_single.SingleMap({"map": MAP5}, name="testmap") + self.map = xymap.XYMap({"map": MAP5}, name="testmap") + self.map.parse() def test_str_output(self): """Check the display_map""" @@ -621,7 +626,8 @@ class TestMap6(TestCase): """ def setUp(self): - self.map = map_single.SingleMap({"map": MAP6}, name="testmap") + self.map = xymap.XYMap({"map": MAP6}, name="testmap") + self.map.parse() def test_str_output(self): """Check the display_map""" @@ -653,7 +659,8 @@ class TestMap7(TestCase): """ def setUp(self): - self.map = map_single.SingleMap({"map": MAP7}, name="testmap") + self.map = xymap.XYMap({"map": MAP7}, name="testmap") + self.map.parse() def test_str_output(self): """Check the display_map""" @@ -681,7 +688,8 @@ class TestMap8(TestCase): """ def setUp(self): - self.map = map_single.SingleMap({"map": MAP8}, name="testmap") + self.map = xymap.XYMap({"map": MAP8}, name="testmap") + self.map.parse() def test_str_output(self): """Check the display_map""" @@ -747,7 +755,8 @@ class TestMap9(TestCase): """ def setUp(self): - self.map = map_single.SingleMap({"map": MAP9}, name="testmap") + self.map = xymap.XYMap({"map": MAP9}, name="testmap") + self.map.parse() def test_str_output(self): """Check the display_map""" @@ -776,7 +785,8 @@ class TestMap10(TestCase): """ def setUp(self): - self.map = map_single.SingleMap({"map": MAP10}, name="testmap") + self.map = xymap.XYMap({"map": MAP10}, name="testmap") + self.map.parse() def test_str_output(self): """Check the display_map""" @@ -824,7 +834,8 @@ class TestMap11(TestCase): """ def setUp(self): - self.map = map_single.SingleMap({"map": MAP11}, name="testmap") + self.map = xymap.XYMap({"map": MAP11}, name="testmap") + self.map.parse() def test_str_output(self): """Check the display_map""" @@ -914,7 +925,8 @@ class TestMapStressTest(TestCase): grid = self._get_grid(Xmax, Ymax) # print(f"\n\n{grid}\n") t0 = time() - map_single.SingleMap({'map': grid}, name="testmap") + mapobj = xymap.XYMap({'map': grid}, name="testmap") + mapobj.parse() 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 +942,8 @@ class TestMapStressTest(TestCase): """ Xmax, Ymax = gridsize grid = self._get_grid(Xmax, Ymax) - mapobj = map_single.SingleMap({'map': grid}, name="testmap") + mapobj = xymap.XYMap({'map': grid}, name="testmap") + mapobj.parse() t0 = time() mapobj._calculate_path_matrix() @@ -962,7 +975,8 @@ class TestMapStressTest(TestCase): """ Xmax, Ymax = gridsize grid = self._get_grid(Xmax, Ymax) - mapobj = map_single.SingleMap({'map': grid}, name="testmap") + mapobj = xymap.XYMap({'map': grid}, name="testmap") + mapobj.parse() t0 = time() mapobj._calculate_path_matrix() diff --git a/evennia/contrib/xyzgrid/utils.py b/evennia/contrib/xyzgrid/utils.py index 8883bd8713..acce60fa3f 100644 --- a/evennia/contrib/xyzgrid/utils.py +++ b/evennia/contrib/xyzgrid/utils.py @@ -46,4 +46,10 @@ class MapParserError(MapError): pass +class MapTransition(RuntimeWarning): + """ + Used when signaling to the parser that a link + leads to another map. + """ + pass diff --git a/evennia/contrib/xyzgrid/map_single.py b/evennia/contrib/xyzgrid/xymap.py similarity index 90% rename from evennia/contrib/xyzgrid/map_single.py rename to evennia/contrib/xyzgrid/xymap.py index 0deb82109b..ed32c685aa 100644 --- a/evennia/contrib/xyzgrid/map_single.py +++ b/evennia/contrib/xyzgrid/xymap.py @@ -1,7 +1,7 @@ r""" -# Map +# XYMap -The `Map` class represents one XY-grid of interconnected map-legend components. It's built from an +The `XYMap` 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). @@ -53,6 +53,13 @@ as up and down. These are indicated in code as 'n', 'ne', 'e', 'se', 's', 'sw', MAP_DATA = { "map": MAP, "legend": LEGEND, + "name": "City of Foo", + "prototypes": { + (0,1): { ... }, + (1,3): { ... }, + ... + } + } ``` @@ -131,7 +138,7 @@ DEFAULT_LEGEND = { # Map parser implementation -class SingleMap: +class XYMap: r""" This represents a single map of interconnected nodes/rooms, parsed from a ASCII map representation. @@ -177,7 +184,7 @@ class SingleMap: # we normally only accept one single character for the legend key legend_key_exceptions = ("\\") - def __init__(self, map_module_or_dict, name="map", other_maps=None): + def __init__(self, map_module_or_dict, name="map", grid=None): """ Initialize the map parser by feeding it the map. @@ -189,9 +196,8 @@ class SingleMap: more than one map. Used when referencing this map during map transitions, baking of pathfinding matrices etc. This will be overridden by any 'name' given in the MAP_DATA itself. - other_maps (dict, optional): Reference to mapping {name: SingleMap, ...} representing - all possible maps one could potentially reach from this map. This is usually - provided by the MutlMap handler. + grid (xyzgrid.XYZGrid, optional): Reference to the top-level grid object, which + stores all maps. This is necessary for transitioning from map to another. Notes: The map deals with two sets of coorinate systems: @@ -211,8 +217,10 @@ class SingleMap: # store so we can reload self.map_module_or_dict = map_module_or_dict - self.other_maps = other_maps - self.room_prototypes = None + self.grid = grid + self.prototypes = None + # transitional mapping + self.symbol_map = None # map setup self.xygrid = None @@ -249,12 +257,14 @@ class SingleMap: def __repr__(self): return f"" - def _parse(self): + def parse_first_pass(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. + Parses the numerical grid from the string. The first pass means parsing out + all nodes. The linking-together of nodes is not happening until the second pass + (the reason for this is that maps can also link to other maps, so all maps need + to have gone through their first parsing-passes before they can be linked together). + + 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 @@ -269,6 +279,8 @@ class SingleMap: XYgrid = defaultdict(dict) # needed by pathfinder node_index_map = {} + # used by transitions + symbol_map = defaultdict(list) mapstring = self.mapstring if mapstring.count(mapcorner_symbol) < 2: @@ -347,44 +359,16 @@ class SingleMap: 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) + mapnode_or_link_class(node_index=node_index, x=ix, y=iy, xymap=self) else: # we have a link at this xygrid position (this is ok everywhere) - xygrid[ix][iy] = mapnode_or_link_class(ix, iy) + xygrid[ix][iy] = mapnode_or_link_class(ix, iy, xymap=self) - # 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) + # store the symbol mapping for transition lookups + symbol_map[char].append(xygrid[ix][iy]) - # 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) - - if self.room_prototypes: - # validate that prototypes are actually all represented by a node on the grid. - node_positions = [] - for node in node_index_map: - # check so every node has a prototype - node_coord = (node.X, node.Y) - node_positions.append(node_coord) - if node_coord not in self.room_prototypes: - raise MapParserError( - f"Symbol '{char}' on XY=({node_coord[0]},{node_coord[1]}) has " - "no corresponding entry in the `rooms` prototype dictionary." - ) - for (iX, iY) in self.room_prototypes: - # also check in the reverse direction - so every prototype has a node - if (iX, iY) not in node_positions: - raise MapParserError( - f"There is a room prototype for XY=({iX},{iY}), but that position " - "of the map grid lacks a node." - ) - - # store + # store results self.max_x, self.max_y = max_x, max_y self.xygrid = xygrid @@ -392,8 +376,49 @@ class SingleMap: self.XYgrid = XYgrid self.node_index_map = node_index_map + self.symbol_map = symbol_map + + def parse_second_pass(self): + """ + Parsing, second pass. Here we loop over all nodes and have them connect to each other via + the detected linkages. For multi-map grids (that links to one another), this must run after + all maps have run through the first pass of their parsing. + + This will create the linkages, build the display map for visualization and validate + all prototypes for the nodes and their connected links. + + """ + node_index_map = self.node_index_map + max_x, max_y = self.max_x, self.max_y + xygrid = self.xygrid + + # build all links + 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, xymap=self) + + # validate and make sure all nodes/links have prototypes + for node in node_index_map.values(): + node_coord = (node.X, node.Y) + # load prototype from override, or use default + node.prototype = self.prototypes.get(node_coord, node.prototype) + # do the same for links (x, y, direction) coords + for direction, maplink in node.first_links.items(): + maplink.prototype = self.prototypes.get(node_coord + (direction,), maplink.prototype) + + # store results self.display_map = display_map + def parse(self): + """Shortcut for running the full parsing of a single map. Useful for testing.""" + self.parse_first_pass() + self.parse_second_pass() + def _get_topology_around_coord(self, coord, dist=2): """ Get all links and nodes up to a certain distance from an XY coordinate. @@ -518,6 +543,7 @@ class SingleMap: mapdata['map'] = variable_from_module(mod, "MAP") mapdata['legend'] = variable_from_module(mod, "LEGEND", default=DEFAULT_LEGEND) mapdata['rooms'] = variable_from_module(mod, "ROOMS") + mapdata['prototypes'] = variable_from_module(mod, "PROTOTYPES", default={}) # validate for key in mapdata.get('legend', DEFAULT_LEGEND): @@ -536,13 +562,11 @@ class SingleMap: # store/update result self.name = mapdata.get('name', self.name) self.mapstring = mapdata['map'] + self.prototypes = mapdata.get('prototypes', {}) # merge the custom legend onto the default legend to allow easily # overriding only parts of it self.legend = {**DEFAULT_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. @@ -571,6 +595,19 @@ class SingleMap: except KeyError: return None + def get_components_with_symbol(self, symbol): + """ + Find all map components (nodes, links) with a given symbol in this map. + + Args: + symbol (char): A single character-symbol to search for. + + Returns: + list: A list of MapNodes and/or MapLinks found with the matching symbol. + + """ + return self.symbol_map.get(symbol, []) + def get_shortest_path(self, startcoord, endcoord): """ Get the shortest route between two points on the grid. diff --git a/evennia/contrib/xyzgrid/xyzgrid.py b/evennia/contrib/xyzgrid/xyzgrid.py new file mode 100644 index 0000000000..23ded12a52 --- /dev/null +++ b/evennia/contrib/xyzgrid/xyzgrid.py @@ -0,0 +1,146 @@ +""" +The grid + +This represents the full XYZ grid, which consists of + +- 2D `Map`-objects parsed from Map strings and Map-legend components. Each represents one + Z-coordinate or location. +- `Prototypes` for how to build each XYZ component into 'real' rooms and exits. +- Actual in-game rooms and exits, mapped to the game based on Map data. + +The grid has three main functions: +- Building new rooms/exits from scratch based on one or more Maps. +- Updating the rooms/exits tied to an existing Map when the Map string + of that map changes. +- Fascilitate communication between the in-game entities and their Map. + + +""" +import itertools +from evennia.scripts.scripts import DefaultScript +from evennia.utils import logger +from .xymap import XYMap + + +class XYZGrid(DefaultScript): + """ + Main grid class. This organizes the Maps based on their name/Z-coordinate. + + """ + def at_script_creation(self): + """ + What we store persistently is the module-paths to each map. + + """ + self.db.map_data = {} + + def reload(self): + """ + Reload the grid. This is done on a server reload and is also necessary if adding a new map + since this may introduce new between-map traversals. + + """ + # build the nodes of each map + for name, xymap in self.grid: + xymap.parse_first_pass() + # link everything together + for name, xymap in self.grid: + xymap.parse_second_pass() + + def add_map(self, mapdata, new=True): + """ + Add new map to the grid. + + Args: + mapdata (dict): A structure `{"map": , "legend": , + "name": , "prototypes": }`. The `prototypes are + coordinate-specific overrides for nodes/links on the map, keyed with their + (X,Y) coordinate (use .5 for link-positions between nodes). + new (bool, optional): If the data should be resaved. + + Raises: + RuntimeError: If mapdata is malformed. + + + Notes: + After this, you need to run `.sync_to_grid` to make the new map actually + available in-game. + + """ + name = mapdata.get('name') + if not name: + raise RuntimeError("XYZGrid.add_map data must contain 'name'.") + + # this will raise MapErrors if there are issues with the map + self.grid[name] = XYMap(mapdata, name=name, grid=self) + if new: + self.db.map_data[name] = mapdata + + def remove_map(self, zcoord, remove_objects=False): + """ + Remove a map from the grid. + + Args: + name (str): The map to remove. + remove_objects (bool, optional): If the synced database objects (rooms/exits) should + be removed alongside this map. + """ + if zcoord in self.grid: + self.db.map_data.pop(zcoord) + self.grid.pop(zcoord) + + if remove_objects: + pass + + def sync_to_grid(self, coord=(None, None, None), direction=None): + """ + Create/recreate/update the in-game grid based on the stored Maps or for a specific Map + or coordinate. + + Args: + coord (tuple, optional): An (X,Y,Z) coordinate, where Z is the name of the map. `None` + acts as a wildcard. + direction (str, optional): A cardinal direction ('n', 'ne' etc). If given, sync only the + exit in the given direction. `None` acts as a wildcard. + + Examples: + - `coord=(1, 3, 'foo')` - sync a specific element of map 'foo' only. + - `coord=(None, None, 'foo') - sync all elements of map 'foo' + - `coord=(1, 3, None) - sync all (1,3) coordinates on all maps (rarely useful) + - `coord=(None, None, None)` - sync all maps. + - `coord=(1, 3, 'foo')`, `direction='ne'` - sync only the north-eastern exit + out of the specific node on map 'foo'. + + """ + x, y, z = coord + + if z is None: + xymaps = self.grid + elif z in self.grid: + xymaps = [self.grid[z]] + else: + raise RuntimeError(f"The 'z' coordinate/name '{z}' is not found on the grid.") + + # first syncing pass (only nodes/rooms) + synced = [] + for xymap in xymaps: + for node in xymap.node_index_map.values(): + if (x is None or x == node.X) and (y is None or y == node.Y): + node.sync_node_to_grid() + synced.append(node) + # second pass (links/exits) + for node in synced: + node.sync_links_to_grid() + + def at_init(self): + """ + Called when the script loads into memory after a reload. This will load all map data into + memory. + + """ + nmaps = 0 + for mapname, mapdata in self.db.map_data: + self.add_map(mapdata, new=False) + nmaps += 1 + self.reload() + logger.log_info(f"Loaded {nmaps} map(s) onto the grid.")