mirror of
https://github.com/evennia/evennia.git
synced 2026-03-28 02:36:32 +01:00
Add block and interrupt nodes/links to map
This commit is contained in:
parent
0926067a33
commit
9c0985cf22
2 changed files with 268 additions and 40 deletions
|
|
@ -146,9 +146,14 @@ class MapNode:
|
|||
tags around it. For further customization, the `.get_display_symbol` method receives
|
||||
the full grid and can return a dynamically determined display symbol. If set to `None`,
|
||||
the `symbol` is used.
|
||||
- `interrupt_path` (bool): If this is set, the shortest-path algorithm will include this
|
||||
node as normally, but stop when reaching it, even if not having reached its target yet. This
|
||||
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).
|
||||
|
||||
"""
|
||||
# symbol used in map definition
|
||||
# symbol used to identify this link on the map
|
||||
symbol = '#'
|
||||
# if printing this node should show another symbol. If set
|
||||
# to the empty string, use `symbol`.
|
||||
|
|
@ -159,8 +164,11 @@ class MapNode:
|
|||
|
||||
# this should always be left True and avoids inifinite loops during querying.
|
||||
multilink = True
|
||||
# this will interrupt a shortest-path step (useful for 'points' of interest, stop before
|
||||
# a door etc).
|
||||
interrupt_path = False
|
||||
|
||||
def __init__(self, x, y, node_index):
|
||||
def __init__(self, x, y, node_index=0):
|
||||
"""
|
||||
Initialize the mapnode.
|
||||
|
||||
|
|
@ -195,6 +203,8 @@ class MapNode:
|
|||
# {startdirection: [direction, ...], ...}
|
||||
# where the directional path-lists also include the start-direction
|
||||
self.xy_steps_to_node = {}
|
||||
# direction-names of the closest neighbors to the node
|
||||
self.closest_neighbor_names = {}
|
||||
|
||||
def __str__(self):
|
||||
return f"<MapNode '{self.symbol}' {self.node_index} XY=({self.X},{self.Y})"
|
||||
|
|
@ -235,6 +245,19 @@ class MapNode:
|
|||
|
||||
if end_node:
|
||||
# the link could be followed to an end node!
|
||||
|
||||
# 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
|
||||
# multi-match error later!
|
||||
first_step_name = steps[0].direction_aliases.get(direction, direction)
|
||||
if first_step_name in self.closest_neighbor_names:
|
||||
raise MapParserError(
|
||||
f"MapNode '{self.symbol}' at XY=({self.X:g},{self.Y:g}) has more "
|
||||
f"than one outgoing direction '{first_step_name}'. All directions "
|
||||
"out of a node must be unique.")
|
||||
self.closest_neighbor_names[first_step_name] = direction
|
||||
|
||||
node_index = end_node.node_index
|
||||
self.weights[node_index] = weight
|
||||
self.links[direction] = end_node
|
||||
|
|
@ -247,8 +270,7 @@ class MapNode:
|
|||
# shortest-route solver
|
||||
shortest_route = self.shortest_route_to_node.get(node_index, ("", [], _BIG))[2]
|
||||
if weight < shortest_route:
|
||||
self.shortest_route_to_node[node_index] = (
|
||||
steps[0].direction_aliases.get(direction, direction), steps, weight)
|
||||
self.shortest_route_to_node[node_index] = (first_step_name, steps, weight)
|
||||
|
||||
def linkweights(self, nnodes):
|
||||
"""
|
||||
|
|
@ -335,10 +357,12 @@ class MapLink:
|
|||
- `multilink` (bool): If set, this link accepts links from all directions. It will usually
|
||||
use a custom get_direction to determine what these are based on surrounding topology. This
|
||||
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.
|
||||
|
||||
"""
|
||||
# link setup
|
||||
symbol = "|"
|
||||
# symbol for identifying this link on the map
|
||||
symbol = ""
|
||||
# if `None`, use .symbol
|
||||
display_symbol = None
|
||||
default_weight = 1
|
||||
|
|
@ -367,6 +391,9 @@ class MapLink:
|
|||
# this shortcuts neighbors trying to figure out if they can connect to this link
|
||||
# - if this is set, they always can (similarly as to a node)
|
||||
multilink = False
|
||||
# this link does not block/reroute pathfinding, but makes the actual path always stop when
|
||||
# trying to cross it.
|
||||
interrupt_path = False
|
||||
|
||||
def __init__(self, x, y):
|
||||
"""
|
||||
|
|
@ -486,6 +513,9 @@ class MapLink:
|
|||
Args:
|
||||
xygrid (dict): 2D dict with x,y coordinates as keys.
|
||||
|
||||
Kwargs:
|
||||
mapinstance (Map): The current Map instance.
|
||||
|
||||
Returns:
|
||||
str: The display-symbol to use. This must visually be a single character
|
||||
but could have color markers, use a unicode font etc.
|
||||
|
|
@ -542,7 +572,7 @@ class SmartMapLink(MapLink):
|
|||
If two nodes are not found, it will link to any combination of links- or nodes as long as
|
||||
it can un-ambiguously determine which direction they lead.
|
||||
|
||||
Placing a limited link directly between two nodes/links will always be a two-way connection,
|
||||
Placing a smart-link directly between two nodes/links will always be a two-way connection,
|
||||
whereas if it connects a node with another link, it will be a one-way connection in the
|
||||
direction of the link.
|
||||
|
||||
|
|
@ -550,7 +580,12 @@ class SmartMapLink(MapLink):
|
|||
::
|
||||
|
||||
#
|
||||
u - moving up and down from the two nodes (two-way)
|
||||
u - moving up in BOTH directions will bring you to the other node (two-way)
|
||||
#
|
||||
|
||||
#
|
||||
d - this better represents the 'real' up/down behavior.
|
||||
u
|
||||
#
|
||||
|
||||
#
|
||||
|
|
@ -558,13 +593,12 @@ class SmartMapLink(MapLink):
|
|||
u
|
||||
#
|
||||
|
||||
#
|
||||
d - (this would be equivalent to the first example but with a longer link)
|
||||
u
|
||||
#-#
|
||||
u - okay since the up-link prioritizes the nodes
|
||||
#
|
||||
|
||||
#u#
|
||||
u - This is okay since they up-links will prefer connecting their nodes
|
||||
u - invalid since top-left node has two 'up' directions to go to
|
||||
#
|
||||
|
||||
# |
|
||||
|
|
@ -580,8 +614,7 @@ class SmartMapLink(MapLink):
|
|||
|
||||
"""
|
||||
# get all visually connected links
|
||||
if not hasattr(self, '_cached_directions'):
|
||||
# rebuild cache
|
||||
if not self.directions:
|
||||
directions = {}
|
||||
neighbors = self.get_linked_neighbors(xygrid)
|
||||
nodes = [direction for direction, neighbor in neighbors.items()
|
||||
|
|
@ -601,8 +634,79 @@ class SmartMapLink(MapLink):
|
|||
f"two nodes or unambiguous link directions. Found neighbor(s) in directions "
|
||||
f"{list(neighbors.keys())}.")
|
||||
|
||||
self._cached_directions = directions
|
||||
return self._cached_directions.get(start_direction)
|
||||
self.directions = directions
|
||||
return self.directions.get(start_direction)
|
||||
|
||||
|
||||
class InvisibleSmartMapLink(SmartMapLink):
|
||||
"""
|
||||
This is a smart maplink that does not show as such on the map - instead it will figure out
|
||||
how it should look had it been one of the 'normal' cardinal-direction links and display
|
||||
itself as that instead. This doesn't change its functionality, only the symbol shown
|
||||
on the map display. This only works for cardinal-direction links.
|
||||
|
||||
It makes use of `display_symbol_aliases` mapping, which maps a sorted set of
|
||||
`((start, end), (end, start))` (two-way) or `((start, end),)` (one-way) directions
|
||||
to a symbol in the current map legend - this is the symbol alias to use. The matching
|
||||
MapLink or MapNode will be initialized at the current position only for the purpose of getting
|
||||
its display_symbol.
|
||||
|
||||
Example:
|
||||
display_symbol_aliases = `{(('n', 's'), ('s', n')): '|', ...}`
|
||||
|
||||
If no `display_symbol_aliases` are given, the regular display_symbol is used.
|
||||
|
||||
"""
|
||||
|
||||
# this allows for normal movement directions even if the invisible-node
|
||||
# is marked with a different symbol.
|
||||
direction_aliases = {
|
||||
'n': 'n', 'ne': 'ne', 'e': 'e', 'se': 'se',
|
||||
's': 's', 'sw': 'sw', 'w': 'w', 'nw': 'nw'
|
||||
}
|
||||
|
||||
# replace current link position with what the smart links "should" look like
|
||||
display_symbol_aliases = {
|
||||
(('n', 's'), ('s', 'n')): '|',
|
||||
(('n', 's'),): 'v',
|
||||
(('s', 'n')): '^',
|
||||
(('e', 'w'), ('w', 'e')): '-',
|
||||
(('e', 'w'),): '>',
|
||||
(('w', 'e'),): '<',
|
||||
(('nw', 'se'), ('sw', 'ne')): '\\',
|
||||
(('ne', 'sw'), ('sw', 'ne')): '/',
|
||||
}
|
||||
|
||||
def get_display_symbol(self, xygrid, **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'
|
||||
|
||||
Depending on how we are connected, we figure out how the 'normal' link
|
||||
should look and use that instead.
|
||||
|
||||
"""
|
||||
if not hasattr(self, "_cached_display_symbol"):
|
||||
mapinstance = kwargs['mapinstance']
|
||||
|
||||
legend = mapinstance.legend
|
||||
default_symbol = (
|
||||
self.symbol if self.display_symbol is None else self.display_symbol)
|
||||
self._cached_display_symbol = default_symbol
|
||||
|
||||
dirtuple = tuple((key, self.directions[key])
|
||||
for key in sorted(self.directions.keys()))
|
||||
|
||||
replacement_symbol = self.display_symbol_aliases.get(dirtuple, default_symbol)
|
||||
|
||||
if replacement_symbol != self.symbol:
|
||||
node_or_link_class = legend.get(replacement_symbol)
|
||||
if node_or_link_class:
|
||||
# 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)
|
||||
return self._cached_display_symbol
|
||||
|
||||
|
||||
class SmartRerouterMapLink(MapLink):
|
||||
|
|
@ -645,8 +749,7 @@ class SmartRerouterMapLink(MapLink):
|
|||
|
||||
"""
|
||||
# get all visually connected links
|
||||
if not hasattr(self, '_cached_directions'):
|
||||
# try to get from cache where possible
|
||||
if not self.directions:
|
||||
directions = {}
|
||||
unhandled_links = list(self.get_linked_neighbors(xygrid).keys())
|
||||
|
||||
|
|
@ -674,14 +777,24 @@ class SmartRerouterMapLink(MapLink):
|
|||
directions[unhandled_links[0]] = unhandled_links[1]
|
||||
directions[unhandled_links[1]] = unhandled_links[0]
|
||||
|
||||
self._cached_directions = directions
|
||||
self.directions = directions
|
||||
|
||||
return self._cached_directions.get(start_direction)
|
||||
return self.directions.get(start_direction)
|
||||
|
||||
|
||||
# ----------------------------------
|
||||
# Default nodes and link classes
|
||||
|
||||
class BasicMapNode(MapNode):
|
||||
"""Basic map Node"""
|
||||
symbol = "#"
|
||||
|
||||
class InterruptMapNode(MapNode):
|
||||
"""A point of interest, where pathfinder will stop"""
|
||||
symbol = "i"
|
||||
display_symbol = "#"
|
||||
interrupt_path = True
|
||||
|
||||
class NSMapLink(MapLink):
|
||||
"""Two-way, North-South link"""
|
||||
symbol = "|"
|
||||
|
|
@ -744,14 +857,35 @@ class WEOneWayMapLink(MapLink):
|
|||
class UpMapLink(SmartMapLink):
|
||||
"""Up direction. Note that this still uses the xygrid!"""
|
||||
symbol = 'u'
|
||||
|
||||
# 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}
|
||||
|
||||
|
||||
class DownMapLink(UpMapLink):
|
||||
"""Works exactly like `UpMapLink` but for the 'down' direction."""
|
||||
symbol = 'd'
|
||||
# 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}
|
||||
|
||||
|
||||
class InterruptMapLink(InvisibleSmartMapLink):
|
||||
"""A (still passable) link that causes the pathfinder to stop before crossing."""
|
||||
symbol = "i"
|
||||
interrupt_path = True
|
||||
|
||||
|
||||
class BlockedMapLink(InvisibleSmartMapLink):
|
||||
"""
|
||||
A high-weight (but still passable) link that causes the shortest-path algorithm to consider this
|
||||
a blocked path. The block will not show up in the map display, paths will just never use this
|
||||
link.
|
||||
|
||||
"""
|
||||
symbol = 'b'
|
||||
weights = {'n': _BIG, 'ne': _BIG, 'e': _BIG, 'se': _BIG,
|
||||
's': _BIG, 'sw': _BIG, 'w': _BIG, 'nw': _BIG}
|
||||
|
||||
|
||||
class RouterMapLink(SmartRerouterMapLink):
|
||||
|
|
@ -762,7 +896,8 @@ class RouterMapLink(SmartRerouterMapLink):
|
|||
# these are all symbols used for x,y coordinate spots
|
||||
# at (0,1) etc.
|
||||
DEFAULT_LEGEND = {
|
||||
"#": MapNode,
|
||||
"#": BasicMapNode,
|
||||
"I": InterruptMapNode,
|
||||
"|": NSMapLink,
|
||||
"-": EWMapLink,
|
||||
"/": NESWMapLink,
|
||||
|
|
@ -776,6 +911,8 @@ DEFAULT_LEGEND = {
|
|||
"o": RouterMapLink,
|
||||
"u": UpMapLink,
|
||||
"d": DownMapLink,
|
||||
"b": BlockedMapLink,
|
||||
"i": InterruptMapLink,
|
||||
}
|
||||
|
||||
# --------------------------------------------
|
||||
|
|
@ -973,7 +1110,6 @@ class Map:
|
|||
"symbols marking the upper- and bottom-left corners of the "
|
||||
"grid area.")
|
||||
|
||||
# from evennia import set_trace;set_trace()
|
||||
# find the the position (in the string as a whole) of the top-left corner-marker
|
||||
maplines = mapstring.split("\n")
|
||||
topleft_marker_x, topleft_marker_y = -1, -1
|
||||
|
|
@ -1059,7 +1195,7 @@ class 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)
|
||||
display_map[iy][ix] = node_or_link.get_display_symbol(xygrid, mapinstance=self)
|
||||
|
||||
# store
|
||||
self.max_x, self.max_y = max_x, max_y
|
||||
|
|
@ -1183,7 +1319,6 @@ class Map:
|
|||
pathfinding_routes = self.pathfinding_routes
|
||||
node_index_map = self.node_index_map
|
||||
|
||||
# from evennia import set_trace;set_trace()
|
||||
path = [endnode]
|
||||
directions = []
|
||||
|
||||
|
|
@ -1193,15 +1328,23 @@ class Map:
|
|||
# we are working backwards).
|
||||
inextnode = pathfinding_routes[istartnode, inextnode]
|
||||
nextnode = node_index_map[inextnode]
|
||||
|
||||
shortest_route_to = nextnode.shortest_route_to_node[path[-1].node_index]
|
||||
|
||||
directions.append(shortest_route_to[0])
|
||||
path.extend(shortest_route_to[1] + [nextnode])
|
||||
path.extend(shortest_route_to[1][::-1] + [nextnode])
|
||||
|
||||
if any(1 for step in shortest_route_to[1] if step.interrupt_path):
|
||||
# detected an interrupt in linkage - discard what we have so far
|
||||
directions = []
|
||||
path = [nextnode]
|
||||
|
||||
if nextnode.interrupt_path and nextnode is not startnode:
|
||||
directions = []
|
||||
path = [nextnode]
|
||||
|
||||
# we have the path - reverse to get the correct order
|
||||
path = path[::-1]
|
||||
directions = directions[::-1]
|
||||
path = path[::-1]
|
||||
|
||||
return directions, path
|
||||
|
||||
|
|
|
|||
|
|
@ -234,26 +234,58 @@ o-o-#-# o
|
|||
MAP9 = r"""
|
||||
+ 0 1 2 3
|
||||
|
||||
3 #-# #-#
|
||||
d d |
|
||||
3 #-#-#-#
|
||||
d d d
|
||||
2 | | |
|
||||
u | u
|
||||
1 #-#-#-#
|
||||
u u u
|
||||
1 #-# #-#
|
||||
u d
|
||||
0 #u# #d#
|
||||
0 #d# #u#
|
||||
|
||||
+ 0 1 2 3
|
||||
|
||||
"""
|
||||
|
||||
MAP9_DISPLAY = r"""
|
||||
#-# #-#
|
||||
d d |
|
||||
| | |
|
||||
u | u
|
||||
#-#-#-#
|
||||
d d d
|
||||
| | |
|
||||
u u u
|
||||
#-# #-#
|
||||
u d
|
||||
#u# #d#
|
||||
#d# #u#
|
||||
""".strip()
|
||||
|
||||
|
||||
MAP10 = r"""
|
||||
|
||||
+ 0 1 2 3
|
||||
|
||||
4 #---#-#
|
||||
b |
|
||||
3 #i#---#
|
||||
|/|
|
||||
2 # #-I-#
|
||||
|
|
||||
1 #-#b#-#
|
||||
| | b
|
||||
0 #b#-#-#
|
||||
|
||||
+ 0 1 2 3
|
||||
|
||||
"""
|
||||
|
||||
# note that I,i,b are invisible
|
||||
MAP10_DISPLAY = r"""
|
||||
#---#-#
|
||||
/ |
|
||||
#-#---#
|
||||
|/|
|
||||
# #-#-#
|
||||
|
|
||||
#-#-#-#
|
||||
| | |
|
||||
#-#-#-#
|
||||
""".strip()
|
||||
|
||||
|
||||
|
|
@ -686,7 +718,7 @@ class TestMap8(TestCase):
|
|||
|
||||
class TestMap9(TestCase):
|
||||
"""
|
||||
Test the Map class with a simple 4-node map
|
||||
Test Map9 - a map with up/down links.
|
||||
|
||||
"""
|
||||
def setUp(self):
|
||||
|
|
@ -699,7 +731,9 @@ class TestMap9(TestCase):
|
|||
|
||||
@parameterized.expand([
|
||||
((0, 0), (0, 1), ('u',)),
|
||||
((0, 0), (1, 0), ('u',)),
|
||||
((0, 0), (1, 0), ('d',)),
|
||||
((1, 0), (2, 1), ('d', 'u', 'e', 'u', 'e', 'd')),
|
||||
((2, 1), (0, 1), ('u', 'w', 'd', 'w')),
|
||||
])
|
||||
def test_shortest_path(self, startcoord, endcoord, expected_directions):
|
||||
"""
|
||||
|
|
@ -708,3 +742,54 @@ class TestMap9(TestCase):
|
|||
"""
|
||||
directions, _ = self.map.get_shortest_path(startcoord, endcoord)
|
||||
self.assertEqual(expected_directions, tuple(directions))
|
||||
|
||||
|
||||
class TestMap10(TestCase):
|
||||
"""
|
||||
Test Map10 - a map with blocked- and interrupt links/nodes. These are
|
||||
'invisible' nodes and won't show up in the map display.
|
||||
|
||||
"""
|
||||
def setUp(self):
|
||||
self.map = mapsystem.Map({"map": MAP10})
|
||||
|
||||
def test_str_output(self):
|
||||
"""Check the display_map"""
|
||||
stripped_map = "\n".join(line.rstrip() for line in str(self.map).split('\n'))
|
||||
self.assertEqual(MAP10_DISPLAY, stripped_map)
|
||||
|
||||
@parameterized.expand([
|
||||
((0, 0), (1, 0), ('n', 'e', 's')),
|
||||
((3, 0), (3, 1), ()), # the blockage hinders this
|
||||
((1, 3), (0, 4), ('e', 'n', 'w', 'w')),
|
||||
((0, 1), (3, 2), ('e', 'n', 'e')), # path interrupted by I node
|
||||
((0, 1), (0, 3), ('e', 'n', 'n')), # path interrupted by i link
|
||||
((1, 3), (0, 3), ()),
|
||||
((3, 2), (2, 2), ('w',)),
|
||||
((3, 2), (1, 2), ('w',)),
|
||||
((3, 3), (0, 3), ('w',)),
|
||||
((2, 2), (3, 2), ('e',)),
|
||||
])
|
||||
def test_shortest_path(self, startcoord, endcoord, expected_directions):
|
||||
"""
|
||||
test shortest-path calculations throughout the grid.
|
||||
|
||||
"""
|
||||
directions, _ = self.map.get_shortest_path(startcoord, endcoord)
|
||||
self.assertEqual(expected_directions, tuple(directions))
|
||||
|
||||
@parameterized.expand([
|
||||
((2, 2), (3, 2), ('e', ), ((2, 2), (2.5, 2), (3, 2))),
|
||||
((3, 3), (0, 3), ('w', ), ((3, 3), (2.5, 3.0), (2.0, 3.0), (1.5, 3.0), (1, 3))),
|
||||
])
|
||||
def test_paths(self, startcoord, endcoord, expected_directions, expected_path):
|
||||
"""
|
||||
Test path locations.
|
||||
|
||||
"""
|
||||
directions, path = self.map.get_shortest_path(startcoord, endcoord)
|
||||
self.assertEqual(expected_directions, tuple(directions))
|
||||
strpositions = [(step.X, step.Y) for step in path]
|
||||
self.assertEqual(expected_path, tuple(strpositions))
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue