mirror of
https://github.com/evennia/evennia.git
synced 2026-03-22 15:56:30 +01:00
Start creating top-level grid class
This commit is contained in:
parent
f40e8c1168
commit
bab2f962f5
8 changed files with 405 additions and 138 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
146
evennia/contrib/xyzgrid/xyzgrid.py
Normal file
146
evennia/contrib/xyzgrid/xyzgrid.py
Normal 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.")
|
||||
Loading…
Add table
Add a link
Reference in a new issue