Start creating top-level grid class

This commit is contained in:
Griatch 2021-06-20 23:13:03 +02:00
parent f40e8c1168
commit bab2f962f5
8 changed files with 405 additions and 138 deletions

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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()

View file

@ -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

View file

@ -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"<Map {self.max_X + 1}x{self.max_Y + 1}, {len(self.node_index_map)} nodes>"
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.

View file

@ -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": <mapstr>, "legend": <legenddict>,
"name": <name>, "prototypes": <dict-of-dicts>}`. 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.")