evennia/map_and_pathfind/mapsystem.py

778 lines
25 KiB
Python

r"""
Implement mapping, with path searching.
This builds a map graph based on an ASCII map-string with special, user-defined symbols.
```python
# in module passed to Map class
MAP = r'''
1
+ 0 1 2 3 4 5 6 7 8 9 0
0 #
\
1 #-#-#-#
|\ |
2 #-#-#-#-----#
| |
3 #-#---#-#-#-#-#
| |x|x|
4 o-#-#-# #-#-#
\ |x|x|
5 o---#-# #-#-#
/
6 #
\
7 #-#-#-#
| |
8 #-#-#-# #
^
9 #-# #
10
'''
LEGEND = {'#': mapsystem.MapNode, '|': mapsystem.NSMapLink,...}
# optional, for more control
MAP_DATA = {
"map": MAP,
"legend": LEGEND,
}
```
The nodes and links can be customized by add your own implementation of `MapNode` or `MapLink` to
the LEGEND dict, mapping them to a particular character symbol.
The single `+` sign in the upper left corner is required and marks the origo of the mapping area and
the 0,0 position will always start one space right and one line down from it. The coordinate axes
numbering is optional, but recommended for readability.
Every x-column should be spaced with one space and the y-rows must have a line between them.
The coordinate positions all corresponds to map 'nodes'. These are usually rooms (which require an
in-game coordinate system to work with the map) but can also be abstract 'link nodes' that links
rooms together and have no in-game equivalence.
All in-between-coordinates positions are reserved for links and no nodes will be detected in those
positions (since it would then not have a proper x,y coordinate).
----
"""
from collections import defaultdict
from scipy.sparse.csgraph import dijkstra
from scipy.sparse import csr_matrix
from scipy import zeros
from evennia.utils.utils import variable_from_module, mod_import
_BIG = 999999999999
_REVERSE_DIRECTIONS = {
"n": "s",
"ne": "sw",
"e": "w",
"se": "nw",
"s": "n",
"sw": "ne",
"w": "e",
"nw": "se"
}
_MAPSCAN = {
"n": (0, 1),
"ne": (1, 1),
"e": (1, 0),
"se": (1, -1),
"s": (0, -1),
"sw": (-1, -1),
"w": (-1, 0),
"nw": (-1, 1)
}
class MapError(RuntimeError):
pass
class MapParserError(MapError):
pass
class MapNode:
"""
This represents a 'room' node on the map.
A node is always located at an (int, int) location
on the map, even if it actually represents a throughput
to another node.
"""
# symbol used in map definition
symbol = '#'
# if printing this node should show another symbol. If set
# to the empty string, use `symbol`.
display_symbol = ''
def __init__(self, x, y, node_index):
"""
Initialize the mapnode.
Args:
x (int): X coordinate. This is the actual room coordinate.
y (int): Y coordinate. This is the actual room coordinate.
node_index (int): This identifies this node with a running
index number required for pathfinding.
"""
self.x = x
self.y = y
self.node_index = node_index
if not self.display_symbol:
self.display_symbol = self.symbol
# 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 = {}
# this maps
self.weights = {}
# lowest direction to a given neighbor
self.cheapest_to_node = {}
def build_links(self, string_map):
"""
Start tracking links in all cardinal directions to
tie this to another node.
Args:
string_map (dict): A 2d dict-of-dicts with x,y coordinates as keys and nodes as values.
"""
# convert room-coordinates back to string-map coordinates
x, y = self.x * 2, self.y * 2
# scan in all directions for links
for direction, (dx, dy) in _MAPSCAN.items():
# note that this is using the string-coordinate system, not the room-one,
# - there are two string coordinates (node + link) per room coordinate
# hence we can step in integer steps
lx, ly = x + dx, y + dy
if lx in string_map and ly in string_map[lx]:
link = string_map[lx][ly]
# just because there is a link here, doesn't mean it's
# connected to this node. If so the `end_node` will be None.
end_node, weight = link.traverse(self, _REVERSE_DIRECTIONS(direction), string_map)
if end_node:
# the link could be followed to an end node!
self.links[direction] = end_node
self.weights[direction] = weight
node_index = end_node.node_index
if weight < self.cheapest_to_node.get(node_index, _BIG):
self.cheapest_to_node[node_index] = direction
def linkweights(self, nnodes):
"""
Retrieve all the weights for the direct links to all other nodes.
Args:
nnodes (int): The total number of nodes
Returns:
scipy.array: Array of weights of the direct links to other nodes.
The weight will be 0 for nodes not directly connected to one another.
Notes:
A node can at most have 8 connections (the cardinal directions).
"""
link_graph = zeros(nnodes)
for node_index, weight in self.weights.items():
link_graph[node_index] = weight
return link_graph
def get_cheapest_link_to(self, node):
"""
Get the cheapest path to a node (there may be several possible).
Args:
node (MapNode): The node to get to.
Returns:
str: The direction (nw, se etc) to get to that node in the cheapest way.
"""
return self.cheapest_to_node[node.node_index]
class MapLink:
"""
This represents a link between up to 8 nodes. A link is always
located on a (.5, .5) location on the map like (1.5, 2.5).
Each link has a 'weight' from 1...inf, whis indicates how 'slow'
it is to traverse that link. This is used by the Dijkstra algorithm
to find the 'fastest' route to a point. By default this weight is 1
for every link, but a locked door, terrain etc could increase this
and have the algorithm prefer to use another route.
It is usually bidirectional, but could also be one-directional.
It is also possible for a link to have some sort of blockage, like
a door.
"""
# link setup
symbol = "|"
display_symbol = ""
# this indicates linkage start:end in 8 cardinal directions on the string-map,
# n,ne,e,se,s,sw,w,nw. A link is described as {startpos:endpoit}, like connecting
# the named corners with a line. If the inverse direction is also possible, it
# must also be specified. So a south-northward, two-way link would be described
# as {"s": "n", "n": "s"}. The get_directions method can be customized to
# dynamically modify this during parsing.
directions = {}
# this is required for pathfinding. Each weight is defined as {startpos:weight}, where
# the startpos is the direction of the cell (n,ne etc) where the link *starts*. The
# weight is a value > 0, smaller than _BIG. The get_directions method can be
# customized to modify this during parsing.
weights = {}
default_weight = 1
# This setting only applies if this is the *first* link in a chain of multiple links. Usually,
# when multiple links are used to tie together two nodes, the default is to average the weight
# across all links. With this disabled, the weights will be added and a long link will be
# considered 'longer' by the pathfinder.
average_long_link_weights = True
def __init__(self, x, y):
"""
Initialize the link.
Args:
x (int): The string-grid X coordinate of the link.
y (int): The string-grid Y coordinate of the link.
"""
self.x = x
self.y = y
if not self.display_symbol:
self.display_symbol = self.symbol
def get_visually_connected(self, string_map, directions=None):
"""
A helper to get all directions to which there appears to be a
visual link/node. This does not trace the link and check weights etc.
Args:
link (MapLink): Currently active link.
string_map (dict): 2D dict with x,y coordinates as keys.
directions (list, optional): The directions (n, ne etc) to check
visual connection to.
Returns:
dict: Mapping {direction: node_or_link} wherever such was found.
"""
if not directions:
directions = _REVERSE_DIRECTIONS
links = {}
for direction in directions:
dx, dy = _MAPSCAN(direction)
end_x, end_y = self.x + dx, self.y + dy
if end_x in string_map and end_y in string_map[end_x]:
links[direction] = string_map[end_x][end_y]
return links
def get_directions(self, start_direction, string_map):
"""
Hook to override for customizing how the directions are
determined.
Args:
start_direction (str): The starting direction (n, ne etc).
string_map (dict): 2D dict with x,y coordinates as keys.
Returns:
dict: The directions map {start_direction:end_direction} of
the link. By default this is just self.directions.
"""
return self.directions
def get_weights(self, start_direction, string_map, current_weight):
"""
Hook to override for customizing how the weights are determined.
Args:
start_direction (str): The starting direction (n, ne etc).
string_map (dict): 2D dict with x,y coordinates as keys.
current_weight (int): This can have an existing value if
we are progressing down a multi-step path.
Returns:
dict: The directions map {start_direction:weight} of
the link. By default this is just self.weights
"""
return self.weights
def traverse(self, start_direction, string_map, _weight=0, _linklen=1):
"""
Recursively traverse a set of links.
Args:
start_direction (str): The direction (n, ne etc) from which
this traversal originates for this link.
string_map (dict): 2D dict with x,y coordinates as keys.
Kwargs:
_weight (int): Internal use.
_linklen (int): Internal use.
Returns:
tuple: The (node, weight) result of the traversal.
Raises:
MapParserError: If a link lead to nowhere.
"""
end_direction = self.get_directions(start_direction, string_map).get(start_direction)
if not end_direction:
raise MapParserError(f"Link at ({self.x}, {self.y}) was connected to "
f"from {start_direction}, but does not link that way.")
dx, dy = _MAPSCAN[end_direction]
end_x, end_y = self.x + dx, self.y + dy
try:
next_target = string_map[end_x][end_y]
except KeyError:
raise MapParserError(f"Link at ({self.x}, {self.y}) points to "
f"empty space in direction {end_direction}!")
_weight += self.get_weights(
start_direction, string_map, _weight).get(
start_direction, self.default_weight)
if hasattr(next_target, "node_index"):
# we reached a node, this is the end of the link.
# we average the weight across all traversed link segments.
return next_target, (
_weight / max(1, _linklen) if self.average_long_link_weights else _weight)
else:
# we hit another link. Progress recursively.
return next_target.traverse(
_REVERSE_DIRECTIONS(end_direction),
string_map, _weight=_weight, _linklen=_linklen + 1)
# ----------------------------------
# Default nodes and link classes
class NSMapLink(MapLink):
symbol = "|"
directions = {"s": "n", "n": "s"}
class EWMapLink(MapLink):
symbol = "-"
directions = {"e": "w", "w": "e"}
class NESWMapLink(MapLink):
symbol = "/"
directions = {"ne": "sw", "sw": "ne"}
class SENWMapLink(MapLink):
symbol = "\\"
directions = {"se": "nw", "nw": "se"}
class CrossMapLink(MapLink):
symbol = "x"
directions = {"ne": "sw", "sw": "ne",
"se": "nw", "nw": "se"}
class PlusMapLink(MapLink):
symbol = "+"
directions = {"s": "n", "n": "s",
"e": "w", "w": "e"}
class NSOneWayMapLink(MapLink):
symbol = "v"
directions = {"n": "s"}
class SNOneWayMapLink(MapLink):
symbol = "^"
directions = {"s": "n"}
class EWOneWayMapLink(MapLink):
symbol = "<"
directions = {"e": "w"}
class WEOneWayMapLink(MapLink):
symbol = ">"
directions = {"w": "e"}
class DynamicMapLink(MapLink):
r"""
This can be used both on a node position and link position but does not represent a location
in-game, but is only intended to help link things together. The dynamic link has no visual
direction so we parse the visual surroundings in the map to see if it's obvious what is
connected to what. If there are links on carinally opposite sites, these are considered
pass-throughs. If determining this is not possible, or there is an uneven number of links, an
error is raised.
::
/
-o - this is ok, there can only be one path
|
-o- - this will be assumed to be two links
|
\|/
-o- - all are passing straight through
/|\
-o- - w-e pass, other is sw-s
/|
-o - invalid
/|
"""
symbol = "o"
def get_directions(self, start_direction, string_map):
# get all visually connected links
directions = {}
links = list(self.get_visually_connected(string_map).keys())
loop_links = links.copy()
# first get all cross-through links
for direction in loop_links:
if _REVERSE_DIRECTIONS[direction] in loop_links:
directions[direction] = links.pop(direction)
# check if we have any non-cross-through paths to handle
if len(links) != 2:
links = "-".join(links)
raise MapParserError(
f"dynamic link at grid ({self.x, self.y}) cannot determine "
f"where how to connect links leading to/from {links}.")
directions[links[0]] = links[1]
directions[links[1]] = links[0]
return directions
# these are all symbols used for x,y coordinate spots
# at (0,1) etc.
DEFAULT_LEGEND = {
"#": MapNode,
"o": MapLink,
"|": NSMapLink,
"-": EWMapLink,
"/": NESWMapLink,
"//": SENWMapLink,
"x": CrossMapLink,
"+": PlusMapLink,
"v": NSOneWayMapLink,
"^": SNOneWayMapLink,
"<": EWOneWayMapLink,
">": WEOneWayMapLink,
}
class Map:
"""
This represents a map of interconnected nodes/rooms. Each room is connected
to each other as a directed graph with optional 'weights' between the the
connections.
This is a parser that parses a string with an ascii-created map into
a 2D-array understood by the Dijkstra algorithm.
The result of this is labeling every node in the tree with a number 0...N.
The grid should be defined to be as readable as possible and every full coordinat must
be separated by a space/empty line. The single `+` in the upper left corner is used to
tell the parser where the axes cross. The (0,0) point will start one space/line away
from this point. The strict numbering is optional (the + is all that's needed), but it's
highly recommended for readability!
::
'''
1 1 1
+ 0 1 2 3 4 5 6 7 8 9 0 1 2 ...
0
1
2
.
.
10
11
.
.
'''
"""
mapcorner_symbol = '+'
max_pathfinding_length = 1000
empty_symbol = ' '
def __init__(self, map_module_or_dict):
"""
Initialize the map parser by feeding it the map.
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 'room_legend' and
'link_legend' dicts to specify the map structure.
"""
# load data from dict or file
mapdata = {}
if isinstance(map_module_or_dict, dict):
mapdata = map_module_or_dict
else:
mod = mod_import(map_module_or_dict)
mapdata = variable_from_module(mod, "MAP_DATA")
if not mapdata:
mapdata['map'] = variable_from_module(mod, "MAP")
mapdata['legend'] = variable_from_module(mod, "LEGEND", default=DEFAULT_LEGEND)
self.mapstring = mapdata['map']
self.node_legend = map_module_or_dict.get("legend", DEFAULT_LEGEND)
self.string_map = None
self.node_map = None
self.display_map = None
self.width = 0
self.height = 0
# Dijkstra algorithm variables
self.node_index_map = None
self.pathfinding_matrix = None
self.dist_matrix = None
self.pathfinding_routes = None
self.parse()
def __str__(self):
return "\n".join(self.display_map)
def parse(self):
"""
Parse the numberical grid in the string. The result of this is a 2D array
of [[MapNode,...], [MapNode, ...]] with MapLinks inside them describing their
linkage to other nodes.
Notes:
"""
mapcorner_symbol = self.mapcorner_symbol
# this allows for [x][y] mapping with arbitrary objects
string_map = defaultdict(dict)
# needed by pathfinder
node_index_map = {}
mapstring = self.mapstring
if mapcorner_symbol not in mapstring:
raise MapParserError("mapstring must have a '+' in the upper left corner to mark "
"the origo of the coordinate system.")
# find the the (xstring, ystring) position where the corner symbol is
maplines = mapstring.split("\n")
mapcorner_x, mapcorner_y = 0, 0
for mapcorner_y, line in maplines:
mapcorner_x = line.find(mapcorner_symbol)
if mapcorner_x != -1:
break
# in-string_position of (x,y)
origo_x, origo_y = mapcorner_x + 2, mapcorner_y + 2
# we have placed the origo, start parsing the grid
node_index = 0
maxwidth = 0
maxheight = 0
# first pass: read string-grid and parse even (x,y) coordinates into nodes
for iy, line in enumerate(maplines[origo_y:]):
maxheight = max(maxheight, iy)
even_iy = iy % 2 == 0
for ix, char in enumerate(line[origo_x:]):
if char == self.empty_symbol:
continue
even_ix = ix % 2 == 0
maxwidth = max(maxwidth, ix)
if even_iy and even_ix:
# a node position will only appear on even positions in the string grid.
mapnode_class = self.node_legend.get(char)
if not mapnode_class:
raise MapParserError(f"Node symbol '{char}' not in NODE_LEGEND.")
if hasattr(mapnode_class, "node_index"):
# this is an actual node that represents an in-game location
# - register it properly.
# the x,y stored on the node is the 'actual' xy position in the game
# world, not just the position in the string map (that is stored
# in the string_map indices instead).
string_map[ix][iy] = self.node_map[ix][iy] = node_index_map[node_index] = \
mapnode_class(node_index=node_index, x=ix // 2, y=iy // 2)
node_index += 1
continue
else:
# we have a link at a coordinate position. Store it but don't add
# it to the node_index_map since it doesn't have an in-game existence.
string_map[ix][iy] = mapnode_class() # actually a linknode class
else:
# an in-between coordinates link
linknode_class = self.link_legend(char)
if not linknode_class:
raise MapParserError(f"Link symbol '{char}' not in LINK_LEGEND.")
string_map[ix][iy] = linknode_class()
# 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:
node.build_links(string_map)
# build display map
display_map = [" " * maxwidth for _ in range(maxheight)]
for ix, ydct in string_map.items():
for iy, node_or_link in ydct.items():
display_map[iy][ix] = node_or_link.display_symbol
# store
self.width = maxwidth
self.height = maxheight
self.string_map = string_map
self.node_index_map = self.node_index_map
self.display_map = display_map
def _get_node_from_coord(self, x, y):
"""
Get a MapNode from a coordinate.
Args:
x (int): X-coordinate on grid.
y (int): Y-coordinate on grid.
"""
if not self.node_2d_map:
self.parse()
try:
self.node_2d_map[x][y]
except IndexError:
raise MapError("_get_node_from_coord got coordinate ({x},{y}) which is "
"outside the grid size of (0,0) - ({self.width}, {self.height}).")
def _calculate_path_matrix(self):
"""
Solve the pathfinding problem using Dijkstra's algorithm.
"""
nnodes = len(self.node_index_map)
pathfinding_graph = zeros((nnodes, nnodes))
# build a matrix representing the map graph, with 0s as impassable areas
for inode, node in self.node_index_map.items():
pathfinding_graph[:, inode] = node.linkweights(nnodes)
# create a sparse matrix to represent link relationships from each node
pathfinding_matrix = csr_matrix(pathfinding_graph)
# solve using Dijkstra's algorithm
self.dist_matrix, self.pathfinding_routes = dijkstra(
pathfinding_matrix, directed=True,
return_predecessors=True, limit=1000)
def get_shortest_path(self, startcoord, endcoord):
"""
Get the shortest route between two points on the grid.
Args:
startcoord (tuple or MapNode): A starting (x,y) coordinate for where
we start from.
endcoord (tuple or MapNode): The end (x,y) coordinate we want to
find the shortest route to.
Returns:
tuple: Two lists, first one containing the shortest sequence of map nodes to
traverse and the second a list of directions (n, se etc) describing the path.
"""
istartnode = self._get_node_from_coord(*endcoord).node_index
endnode = self._get_node_from_coord(*startcoord)
if not self.pathfinding_routes:
self._calculate_path_matrix()
pathfinding_routes = self.pathfinding_routes
node_index_map = self.node_index_map
nodepath = [endnode]
linkpath = []
inextnode = endnode.node_index
while pathfinding_routes[istartnode, inextnode] != -9999:
# the -9999 is set by algorithm for unreachable nodes or end-node
inextnode = pathfinding_routes[istartnode, inextnode]
nodepath.append(node_index_map[inextnode])
linkpath.append(nodepath[-1].get_cheapest_link_to(nodepath[-2]))
# we have the path - reverse to get the correct order
nodepath = nodepath[::-1]
linkpath = linkpath[::-1]
return nodepath, linkpath
def get_map_region(self, x, y, dist=2):
"""
Display the map centered on a point and everything around it within a certain distance.
Args:
x (int): In-world X coordinate.
y (int): In-world Y coordinate.
dist (int): Number of gridpoints distance to show.
A value of 2 will show adjacent nodes, a value
of 1 will only show links from current node.
"""
width, height = self.width, self.height
# convert to string-map coordinates
ix, iy = max(0, min(x * 2, width)), max(0, min(y * 2, height))
left, right = max(0, ix - dist), min(width, ix + dist)
top, bottom = max(0, iy - dist), min(height, iy + dist)
output = []
for line in self.display_map[top:bottom]:
output.append(line[left:right])
return "\n".join(output)