diff --git a/evennia/contrib/xyzgrid/launchcmd.py b/evennia/contrib/xyzgrid/launchcmd.py index 098c8d0bca..df6c0ac62b 100644 --- a/evennia/contrib/xyzgrid/launchcmd.py +++ b/evennia/contrib/xyzgrid/launchcmd.py @@ -16,6 +16,8 @@ Use `evennia xyzgrid help` for usage help. """ +from os.path import join as pathjoin +from django.conf import settings from evennia.contrib.xyzgrid.xyzgrid import get_xyzgrid _HELP_SHORT = """ @@ -37,7 +39,7 @@ evennia xyzgrid init First start of the grid. This will create the XYZGrid global script. No maps are loaded yet! It's safe to run this command multiple times; the grid will only be initialized once. -evennia xyzgrid add path.to.xymap.module +evennia xyzgrid add path.to.xymap.module [,path, path,...] Add one or more XYmaps (each a string-map representing one Z position along with prototypes etc). The module will be parsed for @@ -45,11 +47,12 @@ evennia xyzgrid add path.to.xymap.module - a XYMAP_DATA a dict {"map": mapstring, "zcoord": mapname/zcoord, "legend": dict, "prototypes": dict} describing one single XYmap, or - - a XYMAP_LIST - a list of multiple dicts on the XYMAP_DATA form. This allows to load + - a XYMAP_DATA_LIST - a list of multiple dicts on the XYMAP_DATA form. This allows to load multiple maps from the same module. Note that adding a map does *not* build it. If maps are linked to one another, you should add - all linked maps before building, or you'll get errors when spawning the linking exits. + all linked maps before running 'build', or you'll get errors when creating transitional exits + between maps. evennia xyzgrid build @@ -106,38 +109,77 @@ def _option_list(**suboptions): print(str(xymap)) -def _option_init(**suboptions): +def _option_init(*suboptions): """ Initialize a new grid. Will fail if a Grid already exists. """ grid = get_xyzgrid() - print(f"The grid is initalized as the Script 'XYZGrid'({grid.dbref})") + print(f"The grid is initalized as the Script '{grid.key}'({grid.dbref})") -def _option_add(**suboptions): + +def _option_add(*suboptions): """ - Add a new map to the grid. + Add one or more map to the grid. Supports `add path,path,path,...` """ + grid = get_xyzgrid() + xymap_data_list = [] + for path in suboptions: + xymap_data_list.expand(grid.maps_from_module(path)) + grid.add_maps(*xymap_data_list) -def _option_build(**suboptions): + +def _option_build(*suboptions): """ Build the grid or part of it. """ + grid = get_xyzgrid() -def _option_initpath(**suboptions): + # override grid's logger to echo directly to console + def _log(self, msg): + print(msg) + grid.log = _log + + if suboptions: + opts = ''.join(suboptions).strip('()') + # coordinate tuple + try: + x, y, z = (part.strip() for part in opts.split(",")) + except ValueError: + print("Build coordinate must be given as (X, Y, Z) tuple, where '*' act " + "wild cards and Z is the mapname/z-coord of the map to load.") + return + else: + x, y, z = '*', '*', '*' + + grid.spawn(xyz=(x, y, z)) + + +def _option_initpath(*suboptions): """ - Initialize the pathfinding matrices for grid or part of it. - - """ - -def _option_delete(**suboptions): - """ - Delete the grid or parts of it. + (Re)Initialize the pathfinding matrices for grid or part of it. + + """ + grid = get_xyzgrid() + xymaps = grid.all_rooms() + nmaps = len(xymaps) + for inum, xymap in enumerate(grid.all_rooms()): + print(f"Rebuilding pathfinding matrix for xymap Z={xymap.Z} ({inum+1}/{nmaps}) ...") + xymap.calculate_path_matrix(force=True) + + cachepath = pathjoin(settings.GAMEDIR, "server", ".cache") + print(f"... done. Data cached to {cachepath}.") + + +def _option_delete(*suboptions): + """ + Delete the grid or parts of it. Allows mapname,mapname, ... """ + grid = get_xyzgrid() if not suboptions: repl = input("WARNING: This will delete the ENTIRE Grid and wipe all rooms/exits!" "\nObjects/Chars inside deleted rooms will be moved to their home locations." @@ -146,16 +188,35 @@ def _option_delete(**suboptions): print("Aborted.") else: print("Deleting grid ...") - grid = get_xyzgrid() grid.delete() + else: - pass + zcoords = (part.strip() for part in suboptions) + err = False + for zcoord in zcoords: + if not grid.get_map(zcoord): + print(f"Mapname/zcoord {zcoord} is not a part of the grid.") + err = True + if err: + print("Valid mapnames/zcoords are\n:", "\n ".join( + xymap.Z for xymap in grid.all_rooms())) + return + repl = input("This will delete map(s) {', '.join(zcoords)} and wipe all corresponding " + "rooms/exits!" + "\nObjects/Chars inside deleted rooms will be moved to their home locations." + "\nThis can't be undone. Are you sure you want to continue? Y/[N]?") + if repl.lower() not in ('yes', 'y'): + print("Aborted.") + else: + print("Deleting selected xymaps ...") + + grid.remove_map(*zcoords, remove_objects=True) def xyzcommand(*args): """ Evennia launcher command. This is made available as `evennia xyzgrid` on the command line, - once `settings.EXTRA_LAUNCHER_COMMANDS` is updated. + once added to `settings.EXTRA_LAUNCHER_COMMANDS`. """ if not args: diff --git a/evennia/contrib/xyzgrid/map_example.py b/evennia/contrib/xyzgrid/map_example.py index 67b4955add..8feb5f753b 100644 --- a/evennia/contrib/xyzgrid/map_example.py +++ b/evennia/contrib/xyzgrid/map_example.py @@ -1,68 +1,263 @@ -MAP = r""" +""" +Example xymaps to use with the XYZgrid contrib. Build outside of the game using +the `evennia xyzgrid` launcher command. + +First add the launcher extension in your mygame/server/conf/settings.py: + + EXTRA_LAUNCHER_COMMANDS['xyzgrid'] = 'evennia.contrib.xyzgrid.launchcmd.xyzcommand' + +Then + + evennia xyzgrid init + evennia xyzgrid add evennia.contrib.xyzgrid.map_example + evennia xyzgrid build + + + + +""" + +from evennia.contrib.xyzgrid import map_legend + +# default prototype parent. It's important that +# the typeclass inherits from the XYZRoom (or XYZExit) +# the map_legend.XYZROOM_PARENT and XYZEXIT_PARENTS can also +# be used as a shortcut. + +PARENT = { + "key": "An empty room", + "prototype_key": "xyzmap_room_map1", + "typeclass": "evennia.contrib.xyzgrid.xyzroom.XYZRoom", + "desc": "An empty room." +} + +# -------------------- map 1 - the large tree +# this exemplifies the various map symbols +# but is not heavily prototyped + +MAP1 = r""" 1 + 0 1 2 3 4 5 6 7 8 9 0 -10 #-#-#-#-# - | | \ - 9 #---+---#-#-----I - \ | / - 8 #-#-#-#-# # + 9 #-------#-#-------I + \ / + 8 #-#---# #-t |\ | - 7 #i#-#-#+#-----#-t + 7 #i#-#b--#-t | | - 6 #i#-#---#-#-#-#-# - | |x|x| - 5 o-#-#-# #-#-# - \ / |x|x| - 4 o-o-#-# #-#-# - / / - 3 #-# / # - \ / d - 2 o-o-#-# | - | | u - 1 #-#-#># # - ^ | - 0 T-----#-# #-t + 5 o-#---# + \ / + 4 o-o-#-# + / d + 3 #-----+-------# + | d + 2 | | + v u + 1 #---#>#-# + / + 0 T-# + 0 1 2 3 4 5 6 7 8 9 0 1 """ -# use default legend -LEGEND = { + +class TransitionToCave(map_legend.MapTransitionMapNode): + """ + A transition from map2 to map1 + + """ + symbol = 'T' + target_map_xyz = (2, 3, 'small cave') +# extends the default legend +LEGEND_MAP1 = { + 'T': TransitionToCave } -PARENT = { - "key": "An empty dungeon room", - "prototype_key": "dungeon_doom_prot", - "typeclass": "evennia.contrib.xyzgrid.xyzrooms.XYZRoom", - "desc": "Air is cold and stale in this barren room." -} # link coordinates to rooms -ROOMS = { - (1, 0): { +PROTOTYPES_MAP1 = { + # node/room prototypes + (3, 0): { "key": "Dungeon Entrance", - "prototype_parent": PARENT, - "desc": "A dark entrance." + "desc": "To the west, a narrow opening leads into darkness." }, - (4, 0): { - "key": "Antechamber", - "prototype_parent": PARENT, - "desc": "A small antechamber", + (4, 1): { + "key": "Under the foilage of a giant tree", + "desc": "High above the branches of a giant tree blocs out the sunlight. A slide " + "leading down from the upper branches ends here." + }, + (4, 4): { + "key": "The slide", + "desc": "A slide leads down to the ground from here. It looks like a one-way trip." + }, + (6, 1): { + "key": "Thorny path", + "desc": "To the east is a pathway of thorns. If you get through, you don't think you'll be " + "able to get back here the same way." + }, + (8, 1): { + "key": "By a large tree", + "desc": "You are standing at the root of a great tree." + }, + (8, 3): { + "key": "At the top of the tree", + "desc": "You are at the top of the tree." + }, + (3, 7): { + "key": "Dense foilage", + "desc": "The foilage to the east is extra dense. It will take forever to get through it." + }, + (5, 7): { + "key": "On a huge branch", + "desc": "To the east is a glowing light, may be a teleporter." + }, + (9, 8): { + "key": "On an enormous branch", + "desc": "To the east is a glowing light, may be a teleporter." + }, + (10, 9): { + "key": "A gorgeous view", + "desc": "The view from here is breathtaking, showing the forest stretching far and wide." + }, + # default rooms + ('*', '*'): { + "key": "Among the branches of a giant tree", + "desc": "These branches are wide enough to easily walk on. There's green all around." + }, + # directional prototypes + (3, 0, 'w'): { + "desc": "A dark passage into the underworld." + }, +} + +for prot in PROTOTYPES_MAP1.values(): + prot['prototype_parent'] = PARENT + + +XYMAP_DATA_MAP1 = { + "zcoord": "the large tree", + "map": MAP1, + "legend": LEGEND_MAP1, + "prototypes": PROTOTYPES_MAP1 +} + +# ------------- map2 definitions - small cave +# this gives prototypes for every room + +MAP2 = r""" ++ 0 1 2 3 + +3 #-#-# + |x| +2 #-#-# + | \ +1 #---# + | / +0 T-#-# + ++ 0 1 2 3 + +""" + +# custom map node +class TransitionToLargeTree(map_legend.MapTransitionMapNode): + """ + A transition from map1 to map2 + + """ + symbol = 'T' + target_map_xyz = (3, 0, 'the large tree') + + +# this extends the default legend (that defines #,-+ etc) +LEGEND_MAP2 = { + "T": TransitionToLargeTree +} + +# prototypes for specific locations +PROTOTYPES_MAP2 = { + # node/rooms prototype overrides + (1, 0): { + "key": "The entrance", + "desc": "This is the entrance to a small cave leading into the ground. " + "Light sifts in from the outside, while cavernous passages disappear " + "into darkness." + }, + (2, 0): { + "key": "A gruesome sight.", + "desc": "Something was killed here recently. The smell is unbearable." + }, + (1, 1): { + "key": "A dark pathway", + "desc": "The path splits three ways here. To the north a faint light can be seen." + }, + (3, 2): { + "key": "Stagnant water", + "desc": "A pool of stagnant, black water dominates this small chamber. To the nortwest " + "a faint light can be seen." + }, + (0, 2): { + "key": "A dark alcove", + "desc": "This alcove is empty." + }, + (1, 2): { + "key": "South-west corner of the atrium", + "desc": "Sunlight sifts down into a large underground chamber. Weeds and grass sprout " + "between the stones." + }, + (2, 2): { + "key": "South-east corner of the atrium", + "desc": "Sunlight sifts down into a large underground chamber. Weeds and grass sprout " + "between the stones." + }, + (1, 3): { + "key": "North-west corner of the atrium", + "desc": "Sunlight sifts down into a large underground chamber. Weeds and grass sprout " + "between the stones." + }, + (2, 3): { + "key": "North-east corner of the atrium", + "desc": "Sunlight sifts down into a large underground chamber. Weeds and grass sprout " + "between the stones. To the east is a dark passage." + }, + (3, 3): { + "key": "Craggy crevice", + "desc": "This is the deepest part of the dungeon. The path shrinks away and there " + "is no way to continue deeper." + }, + # default fallback for undefined nodes + ('*', '*'): { + "key": "A dark room", + "desc": "A dark, but empty, room." + }, + # directional prototypes + (1, 0, 'w'): { + "desc": "A narrow path to the fresh air of the outside world." + }, + # directional fallbacks for unset directions + ('*', '*', '*'): { + "desc": "A dark passage" } } +# this is required by the prototypes, but we add it all at once so we don't +# need to add it to every line above +for prot in PROTOTYPES_MAP2.values(): + prot['prototype_parent'] = PARENT -MAP_DATA = { - "name": "Dungeon of Doom", - "map": MAP, - "legend": LEGEND, - "rooms": ROOMS, + +XYMAP_DATA_MAP2 = { + "map": MAP2, + "zcoord": "the small cave", + "legend": LEGEND_MAP2, + "prototypes": PROTOTYPES_MAP2 } -XYMAP_LIST = [ - MAP_DATA +# This is read by the parser +XYMAP_DATA_LIST = [ + XYMAP_DATA_MAP1, + XYMAP_DATA_MAP2 ] diff --git a/evennia/contrib/xyzgrid/map_legend.py b/evennia/contrib/xyzgrid/map_legend.py index fd51957187..c6fd3294a4 100644 --- a/evennia/contrib/xyzgrid/map_legend.py +++ b/evennia/contrib/xyzgrid/map_legend.py @@ -79,8 +79,8 @@ class MapNode: 'sw': ('southwest', 'sw', 'south-west'), 'w': ('west', 'w'), 'nw': ('northwest', 'nw', 'north-west'), - 'd' : ('down', 'd', 'do'), - 'u' : ('up', 'u'), + 'd': ('down', 'd', 'do'), + 'u': ('up', 'u'), } def __init__(self, x, y, Z, node_index=0, xymap=None): @@ -261,6 +261,7 @@ class MapNode: return xyz = self.get_spawn_xyz() + print("xyz:", xyz, self.node_index) try: nodeobj = NodeTypeclass.objects.get_xyz(xyz=xyz) @@ -944,9 +945,10 @@ class SmartMapLink(MapLink): directions[direction] = REVERSE_DIRECTIONS[direction] else: raise MapParserError( - f"must have exactly two connections - either " - f"two nodes or unambiguous link directions. Found neighbor(s) in directions " - f"{list(neighbors.keys())}.", self) + "must have exactly two connections - either directly to " + "two nodes or connecting directly to one node and with exactly one other " + f"link direction. The neighbor(s) in directions {list(neighbors.keys())} do " + "not fulfill these criteria.", self) self.directions = directions return self.directions.get(start_direction) @@ -1027,7 +1029,7 @@ class InvisibleSmartMapLink(SmartMapLink): class BasicMapNode(MapNode): """Basic map Node""" symbol = "#" - prototype = "xyz_room_prototype" + prototype = "xyz_room" class MapTransitionMapNode(TransitionMapNode): @@ -1043,35 +1045,35 @@ class InterruptMapNode(MapNode): symbol = "I" display_symbol = "#" interrupt_path = True - prototype = "xyz_room_prototype" + prototype = "xyz_room" class NSMapLink(MapLink): """Two-way, North-South link""" symbol = "|" directions = {"n": "s", "s": "n"} - prototype = "xyz_exit_prototype" + prototype = "xyz_exit" class EWMapLink(MapLink): """Two-way, East-West link""" symbol = "-" directions = {"e": "w", "w": "e"} - prototype = "xyz_exit_prototype" + prototype = "xyz_exit" class NESWMapLink(MapLink): """Two-way, NorthWest-SouthWest link""" symbol = "/" directions = {"ne": "sw", "sw": "ne"} - prototype = "xyz_exit_prototype" + prototype = "xyz_exit" class SENWMapLink(MapLink): """Two-way, SouthEast-NorthWest link""" symbol = "\\" directions = {"se": "nw", "nw": "se"} - prototype = "xyz_exit_prototype" + prototype = "xyz_exit" class PlusMapLink(MapLink): @@ -1079,7 +1081,7 @@ class PlusMapLink(MapLink): symbol = "+" directions = {"s": "n", "n": "s", "e": "w", "w": "e"} - prototype = "xyz_exit_prototype" + prototype = "xyz_exit" class CrossMapLink(MapLink): @@ -1087,35 +1089,35 @@ class CrossMapLink(MapLink): symbol = "x" directions = {"ne": "sw", "sw": "ne", "se": "nw", "nw": "se"} - prototype = "xyz_exit_prototype" + prototype = "xyz_exit" class NSOneWayMapLink(MapLink): """One-way North-South link""" symbol = "v" directions = {"n": "s"} - prototype = "xyz_exit_prototype" + prototype = "xyz_exit" class SNOneWayMapLink(MapLink): """One-way South-North link""" symbol = "^" directions = {"s": "n"} - prototype = "xyz_exit_prototype" + prototype = "xyz_exit" class EWOneWayMapLink(MapLink): """One-way East-West link""" symbol = "<" directions = {"e": "w"} - prototype = "xyz_exit_prototype" + prototype = "xyz_exit" class WEOneWayMapLink(MapLink): """One-way West-East link""" symbol = ">" directions = {"w": "e"} - prototype = "xyz_exit_prototype" + prototype = "xyz_exit" class UpMapLink(SmartMapLink): @@ -1125,7 +1127,7 @@ class UpMapLink(SmartMapLink): # all movement over this link is 'up', regardless of where on the xygrid we move. direction_aliases = {'n': symbol, 'ne': symbol, 'e': symbol, 'se': symbol, 's': symbol, 'sw': symbol, 'w': symbol, 'nw': symbol} - prototype = "xyz_exit_prototype" + prototype = "xyz_exit" class DownMapLink(UpMapLink): @@ -1134,14 +1136,14 @@ class DownMapLink(UpMapLink): # all movement over this link is 'down', regardless of where on the xygrid we move. direction_aliases = {'n': symbol, 'ne': symbol, 'e': symbol, 'se': symbol, 's': symbol, 'sw': symbol, 'w': symbol, 'nw': symbol} - prototype = "xyz_exit_prototype" + prototype = "xyz_exit" class InterruptMapLink(InvisibleSmartMapLink): """A (still passable) link that causes the pathfinder to stop before crossing.""" symbol = "i" interrupt_path = True - prototype = "xyz_exit_prototype" + prototype = "xyz_exit" class BlockedMapLink(InvisibleSmartMapLink): @@ -1154,7 +1156,7 @@ class BlockedMapLink(InvisibleSmartMapLink): symbol = 'b' weights = {'n': BIGVAL, 'ne': BIGVAL, 'e': BIGVAL, 'se': BIGVAL, 's': BIGVAL, 'sw': BIGVAL, 'w': BIGVAL, 'nw': BIGVAL} - prototype = "xyz_exit_prototype" + prototype = "xyz_exit" class RouterMapLink(SmartRerouterMapLink): diff --git a/evennia/contrib/xyzgrid/maprunner.py b/evennia/contrib/xyzgrid/maprunner.py deleted file mode 100644 index 26c808f409..0000000000 --- a/evennia/contrib/xyzgrid/maprunner.py +++ /dev/null @@ -1,25 +0,0 @@ -""" -Maprunner - -This is a stand-alone program for baking and preparing grid-maps. - -## Baking - -The Dijkstra algorithm is very powerful for pathfinding, but for very large grids it can be slow -to build the initial distance-matrix. As an example, for an extreme case of 10 000 nodes, all -connected along all 8 cardinal directions, there are so many possible combinations that it -takes about 25 seconds on medium hardware to build the matrix. 40 000 nodes takes about 9 minutes. - -Once the matrix is built, pathfinding across the entire grid is a <0.1s operation however. So as -long as the grid doesn't change, it's a good idea to pre-build it. Pre-building like this is -often referred to as 'baking' the asset. - -This program will build and run the Dijkstra on a given map and store the result as a -serialized binary file in the `mygame/server/.cache/ directory. If it exists, the Map -will load this file. If the map changed since it was saved, the file will be automatically -be rebuilt. - -""" - - - diff --git a/evennia/contrib/xyzgrid/prototypes.py b/evennia/contrib/xyzgrid/prototypes.py index ba742483a0..6ba5d875a9 100644 --- a/evennia/contrib/xyzgrid/prototypes.py +++ b/evennia/contrib/xyzgrid/prototypes.py @@ -1,35 +1,32 @@ """ -Prototypes for building the XYZ-grid into actual game-rooms. +Default prototypes for building the XYZ-grid into actual game-rooms. Add this to mygame/conf/settings/settings.py: PROTOTYPE_MODULES += ['evennia.contrib.xyzgrid.prototypes'] +The prototypes can then be used in mapping prototypes as + + {'prototype_parent': 'xyz_room', ...} + +and/or + + {'prototype_parent': 'xyz_exit', ...} + """ -# Note - the XYZRoom/exit parents track the XYZ coordinates automatically -# so we don't need to add custom tags to them here. -_ROOM_PARENT = { - 'prototype_tags': ("xyzroom", ), - 'typeclass': 'evennia.contrib.xyzgrid.xyzroom.XYZRoom' -} - -_EXIT_PARENT = { - 'prototype_tags': ("xyzexit", ), - 'typeclass': 'evennia.contrib.xyzgrid.xyzroom.XYZExit' -} - +# required by the prototype importer PROTOTYPE_LIST = [ { - 'prototype_key': 'xyz_room_prototype', - 'prototype_parent': _ROOM_PARENT, - 'key': "A non-descript room", - },{ - 'prototype_key': 'xyz_transition_room_prototype', - 'prototype_parent': _ROOM_PARENT, - 'typeclass': 'evennia.contrib.xyzgrid.xyzroom.XYZMapTransitionRoom', - },{ - 'prototype_key': 'xyz_exit_prototype', - 'prototype_parent': _EXIT_PARENT, + 'prototype_key': 'xyz_room', + 'typeclass': 'evennia.contrib.xyzgrid.xyzroom.XYZRoom', + 'prototype_tags': ("xyzroom", ), + 'key': "A room", + 'desc': "An empty room." + }, { + 'prototype_key': 'xyz_exit', + 'prototype_tags': ("xyzexit", ), + 'typeclass': 'evennia.contrib.xyzgrid.xyzroom.XYZExit', + 'desc': "An exit." } ] diff --git a/evennia/contrib/xyzgrid/tests.py b/evennia/contrib/xyzgrid/tests.py index cf0b22983b..e58a883253 100644 --- a/evennia/contrib/xyzgrid/tests.py +++ b/evennia/contrib/xyzgrid/tests.py @@ -349,11 +349,11 @@ class _MapTest(TestCase): map_data = {'map': MAP1, 'zcoord': "map1"} map_display = MAP1_DISPLAY - def setUp(self): + def setUp(self): """Set up grid and map""" self.grid, err = xyzgrid.XYZGrid.create("testgrid") self.grid.add_maps(self.map_data) - self.map = self.grid.get(self.map_data['zcoord']) + self.map = self.grid.get_map(self.map_data['zcoord']) def tearDown(self): self.grid.delete() @@ -1155,7 +1155,7 @@ class TestXYZGrid(TestCase): def test_str_output(self): """Check the display_map""" - xymap = self.grid.get(self.zcoord) + xymap = self.grid.get_map(self.zcoord) stripped_map = "\n".join(line.rstrip() for line in str(xymap).split('\n')) self.assertEqual(MAP1_DISPLAY, stripped_map) @@ -1215,7 +1215,7 @@ class TestXYZGridTransition(TestCase): test shortest-path calculations throughout the grid. """ - directions, _ = self.grid.get('map12a').get_shortest_path(startcoord, endcoord) + directions, _ = self.grid.get_map('map12a').get_shortest_path(startcoord, endcoord) self.assertEqual(expected_directions, tuple(directions)) def test_spawn(self): @@ -1236,3 +1236,41 @@ class TestXYZGridTransition(TestCase): # make sure exits traverse the maps self.assertEqual(east_exit.db_destination, room2) self.assertEqual(west_exit.db_destination, room1) + +class TestBuildExampleGrid(TestCase): + """ + Test building the map_example + + """ + def setUp(self): + # build and populate grid + self.grid, err = xyzgrid.XYZGrid.create("testgrid") + + def tearDown(self): + self.grid.delete() + + def test_build(self): + """ + Build the map example. + + """ + mapdatas = self.grid.maps_from_module("evennia.contrib.xyzgrid.map_example") + self.assertEqual(len(mapdatas), 2) + + self.grid.add_maps(*mapdatas) + self.grid.spawn() + + # testing + room1a = xyzroom.XYZRoom.objects.get_xyz(xyz=(3, 0, 'the large tree')) + room1b = xyzroom.XYZRoom.objects.get_xyz(xyz=(10, 9, 'the large tree')) + room2a = xyzroom.XYZRoom.objects.get_xyz(xyz=(1, 0, 'small cave')) + room2b = xyzroom.XYZRoom.objects.get_xyz(xyz=(1, 3, 'small cave')) + + self.assertEqual(room1a.key, "Dungeon Entrance") + self.assertTrue(room1a.desc.startswith("To the west")) + self.assertEqual(room1b.key, "A gorgeous view") + self.assertTrue(room1b.desc.startswith("The view from here is breathtaking.")) + self.assertEqual(room2a.key, "The entrance") + self.assertTrue(room2a.desc.startswith("This is the entrance to")) + self.assertEqual(room2b.key, "North-west corner of the atrium") + self.assertTrue(room2b.desc.startswith("Sunlight sifts down")) diff --git a/evennia/contrib/xyzgrid/utils.py b/evennia/contrib/xyzgrid/utils.py index acce60fa3f..04b6fe7b71 100644 --- a/evennia/contrib/xyzgrid/utils.py +++ b/evennia/contrib/xyzgrid/utils.py @@ -36,7 +36,7 @@ class MapError(RuntimeError): prefix = "" if node_or_link: prefix = (f"{node_or_link.__class__.__name__} '{node_or_link.symbol}' " - f"at XY=({node_or_link.X:g},{node_or_link.Y:g}) ") + f"at XYZ=({node_or_link.X:g},{node_or_link.Y:g},{node_or_link.Z}) ") self.node_or_link = node_or_link self.message = f"{prefix}{error}" super().__init__(self.message) diff --git a/evennia/contrib/xyzgrid/xymap.py b/evennia/contrib/xyzgrid/xymap.py index 4f4266af7d..d371876788 100644 --- a/evennia/contrib/xyzgrid/xymap.py +++ b/evennia/contrib/xyzgrid/xymap.py @@ -12,9 +12,7 @@ as up and down. These are indicated in code as 'n', 'ne', 'e', 'se', 's', 'sw', ```python - # in module passed to 'Map' class. It will either a dict - # MAP_DATA with keys 'map' and (optionally) 'legend', or - # the MAP/LEGEND variables directly. + # in module passed to 'Map' class MAP = r''' 1 @@ -47,10 +45,11 @@ as up and down. These are indicated in code as 'n', 'ne', 'e', 'se', 's', 'sw', ''' + LEGEND = {'#': xyzgrid.MapNode, '|': xyzgrid.NSMapLink,...} - # optional, for more control - MAP_DATA = { + # read by parser if XYMAP_DATA_LIST doesn't exist + XYMAP_DATA = { "map": MAP, "legend": LEGEND, "zcoord": "City of Foo", @@ -62,6 +61,11 @@ as up and down. These are indicated in code as 'n', 'ne', 'e', 'se', 's', 'sw', } + # will be parsed first, allows for multiple map-data dicts from one module + XYMAP_DATA_LIST = [ + XYMAP_DATA + ] + ``` The two `+` signs in the upper/lower left corners are required and marks the edge of the map area. @@ -102,7 +106,7 @@ except ImportError as err: f"{err}\nThe XYZgrid contrib requires " "the SciPy package. Install with `pip install scipy'.") from django.conf import settings -from evennia.utils.utils import variable_from_module, mod_import +from evennia.utils.utils import variable_from_module, mod_import, is_iter from evennia.utils import logger from evennia.prototypes import prototypes as protlib @@ -140,7 +144,6 @@ DEFAULT_LEGEND = { } - # -------------------------------------------- # Map parser implementation @@ -309,13 +312,18 @@ class XYMap: else: # read from contents of module mod = mod_import(map_module_or_dict) - mapdata = variable_from_module(mod, "MAP_DATA") + mapdata_list = variable_from_module(mod, "XYMAP_DATA_LIST") + if mapdata_list and self.Z: + # use the stored Z value to figure out which map data we want + mapping = {mapdata.get("zcoord") for mapdata in mapdata_list} + mapdata = mapping.get(self.Z, {}) + if not mapdata: - # try to read mapdata directly from global variables - mapdata['zcoord'] = variable_from_module(mod, "ZCOORD", default=self.name) - mapdata['map'] = variable_from_module(mod, "MAP") - mapdata['legend'] = variable_from_module(mod, "LEGEND", default=DEFAULT_LEGEND) - mapdata['prototypes'] = variable_from_module(mod, "PROTOTYPES", default={}) + mapdata = variable_from_module(mod, "XYMAP_DATA") + + if not mapdata: + raise MapError("No valid XYMAP_DATA or XYMAP_DATA_LIST could be found from " + f"{map_module_or_dict}.") # validate if any(key for key in mapdata if key not in MAP_DATA_KEYS): @@ -330,10 +338,9 @@ class XYMap: "`.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.") + raise MapError("No map found. Add 'map' key to map-data dict.") for key, prototype in mapdata.get('prototypes', {}).items(): - if not is_iter(key) and (2 <= len(key) <= 3): + if not (is_iter(key) and (2 <= len(key) <= 3)): raise MapError(f"Prototype override key {key} is malformed: It must be a " "coordinate (X, Y) for nodes or (X, Y, direction) for links; " "where direction is a supported direction string ('n', 'ne', etc).") @@ -491,10 +498,13 @@ class XYMap: 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) + node.prototype = self.prototypes.get( + node_coord, self.prototypes.get(('*', '*'), 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) + maplink.prototype = self.prototypes.get( + node_coord + (direction,), + self.prototypes.get(('*', '*', '*'), maplink.prototype)) # store self.display_map = display_map @@ -547,13 +557,16 @@ class XYMap: points, xmin, xmax, ymin, ymax = _scan_neighbors(center_node, [], dist=dist) return list(set(points)), xmin, xmax, ymin, ymax - def calculate_path_matrix(self): + def calculate_path_matrix(self, force=False): """ Solve the pathfinding problem using Dijkstra's algorithm. This will try to load the solution from disk if possible. + Args: + force (bool, optional): If the cache should always be rebuilt. + """ - if self.pathfinder_baked_filename and isfile(self.pathfinder_baked_filename): + if not force and self.pathfinder_baked_filename and isfile(self.pathfinder_baked_filename): # check if the solution for this grid was already solved previously. mapstr, dist_matrix, pathfinding_routes = "", None, None @@ -569,7 +582,6 @@ class XYMap: # we can re-use the stored data! self.dist_matrix = dist_matrix self.pathfinding_routes = pathfinding_routes - return # build a matrix representing the map graph, with 0s as impassable areas @@ -615,7 +627,7 @@ class XYMap: wildcard = '*' spawned = [] - for node in self.node_index_map.values(): + for node in sorted(self.node_index_map.values(), key=lambda n: (n.Y, n.X)): if (x in (wildcard, node.X)) and (y in (wildcard, node.Y)): node.spawn() spawned.append(node) @@ -643,7 +655,7 @@ class XYMap: wildcard = '*' if not nodes: - nodes = self.node_index_map.values() + nodes = sorted(self.node_index_map.values(), key=lambda n: (n.Z, n.Y, n.X)) for node in nodes: if (x in (wildcard, node.X)) and (y in (wildcard, node.Y)): diff --git a/evennia/contrib/xyzgrid/xyzgrid.py b/evennia/contrib/xyzgrid/xyzgrid.py index e94219b989..a9f14a10ed 100644 --- a/evennia/contrib/xyzgrid/xyzgrid.py +++ b/evennia/contrib/xyzgrid/xyzgrid.py @@ -18,8 +18,9 @@ The grid has three main functions: """ from evennia.scripts.scripts import DefaultScript from evennia.utils import logger +from evennia.utils.utils import variable_from_module, make_iter from .xymap import XYMap -from .xyzroom import XYZRoom +from .xyzroom import XYZRoom, XYZExit class XYZGrid(DefaultScript): @@ -40,7 +41,7 @@ class XYZGrid(DefaultScript): self.reload() return self.ndb.grid - def get(self, zcoord): + def get_map(self, zcoord): """ Get a specific xymap. @@ -53,7 +54,7 @@ class XYZGrid(DefaultScript): """ return self.grid.get(zcoord) - def all(self): + def all_maps(self): """ Get all xymaps stored in the grid. @@ -63,20 +64,55 @@ class XYZGrid(DefaultScript): """ return self.grid + def log(self, msg): + logger.log_info(f"|grid| {msg}") + + def get_room(xyz, **kwargs): + """ + Get room object from XYZ coordinate. + + Args: + xyz (tuple): X,Y,Z coordinate of room to fetch. + + Returns: + XYZRoom: The found room. + + Raises: + XYZRoom.DoesNotExist: If room is not found. + + Notes: + This assumes the room was previously built. + + """ + return XYZRoom.objects.get_xyz(xyz=xyz, **kwargs) + + def get_exit(xyz, name='north', **kwargs): + """ + Get exit object at coordinate. + + Args: + xyz (tuple): X,Y,Z coordinate of the room the + exit leads out of. + name (str): The full name of the exit, e.g. 'north' or 'northwest'. + + """ + kwargs['db_key'] = name + return XYZExit.objects.get_xyz_exit(xyz=xyz, **kwargs) + def reload(self): """ Reload and rebuild 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. """ - logger.log_info("[grid] (Re)loading grid ...") + self.log("(Re)loading grid ...") self.ndb.grid = {} nmaps = 0 # generate all Maps - this will also initialize their components # and bake any pathfinding paths (or load from disk-cache) for zcoord, mapdata in self.db.map_data.items(): - logger.log_info(f"[grid] Loading map '{zcoord}'...") + self.log(f"Loading map '{zcoord}'...") xymap = XYMap(dict(mapdata), Z=zcoord, xyzgrid=self) xymap.parse() xymap.calculate_path_matrix() @@ -84,7 +120,7 @@ class XYZGrid(DefaultScript): nmaps += 1 # store - logger.log_info(f"[grid] Loaded and linked {nmaps} map(s).") + self.log(f"Loaded and linked {nmaps} map(s).") def at_init(self): """ @@ -94,6 +130,27 @@ class XYZGrid(DefaultScript): """ self.reload() + def maps_from_module(self, module): + """ + Load map data from module. The loader will look for a dict XYMAP_DATA or a list of + XYMAP_DATA_LIST (a list of XYMAP_DATA dicts). Each XYMAP_DATA dict should contain + `{"xymap": mapstring, "zcoord": mapname/zcoord, "legend": dict, "prototypes": dict}`. + + Args: + module (module or str): A module or python-path to a module containing + map data as either `XYMAP_DATA` or `XYMAP_DATA_LIST` variables. + + Returns: + list: List of zero, one or more xy-map data dicts loaded from the module. + + """ + map_data_list = variable_from_module(module, "XYMAP_DATA_LIST") + if not map_data_list: + map_data_list = variable_from_module(module, "XYMAP_DATA") + if map_data_list: + map_data_list = make_iter(map_data_list) + return map_data_list + def add_maps(self, *mapdatas): """ Add map or maps to the grid. @@ -176,12 +233,12 @@ class XYZGrid(DefaultScript): # first build all nodes/rooms for zcoord, xymap in xymaps.items(): - logger.log_info(f"[grid] spawning/updating nodes for {zcoord} ...") + self.log(f"spawning/updating nodes for {zcoord} ...") xymap.spawn_nodes(xy=(x, y)) # next build all links between nodes (including between maps) for zcoord, xymap in xymaps.items(): - logger.log_info(f"[grid] spawning/updating links for {zcoord} ...") + self.log(f"spawning/updating links for {zcoord} ...") xymap.spawn_links(xy=(x, y), directions=directions) diff --git a/evennia/contrib/xyzgrid/xyzroom.py b/evennia/contrib/xyzgrid/xyzroom.py index d9dd3984a7..2b9188ca7d 100644 --- a/evennia/contrib/xyzgrid/xyzroom.py +++ b/evennia/contrib/xyzgrid/xyzroom.py @@ -10,7 +10,6 @@ used as stand-alone XYZ-coordinate-aware rooms. from django.db.models import Q from evennia.objects.objects import DefaultRoom, DefaultExit from evennia.objects.manager import ObjectManager -from evennia.utils.utils import inherits_from # name of all tag categories. Note that the Z-coordinate is # the `map_name` of the XYZgrid @@ -50,8 +49,6 @@ class XYZManager(ObjectManager): x, y, z = xyz wildcard = '*' - - return ( self .filter_family(**kwargs)