mirror of
https://github.com/evennia/evennia.git
synced 2026-03-29 12:07:17 +02:00
778 lines
25 KiB
Python
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)
|