From 417c52e87171efe49b9df8852dd60b0a1b95ebda Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 13 Jul 2021 23:31:24 +0200 Subject: [PATCH] Fixes to goto functionality. Working well now. --- evennia/contrib/xyzgrid/commands.py | 232 +++++++++++++++++++++++--- evennia/contrib/xyzgrid/map_legend.py | 46 +++-- evennia/contrib/xyzgrid/xymap.py | 4 +- evennia/contrib/xyzgrid/xyzroom.py | 6 +- 4 files changed, 246 insertions(+), 42 deletions(-) diff --git a/evennia/contrib/xyzgrid/commands.py b/evennia/contrib/xyzgrid/commands.py index 7420bd158b..912894606a 100644 --- a/evennia/contrib/xyzgrid/commands.py +++ b/evennia/contrib/xyzgrid/commands.py @@ -7,17 +7,23 @@ the commands with XYZ-aware equivalents. """ +from collections import namedtuple from django.conf import settings from evennia import InterruptCommand from evennia import default_cmds, CmdSet from evennia.commands.default import building from evennia.contrib.xyzgrid.xyzroom import XYZRoom from evennia.contrib.xyzgrid.xyzgrid import get_xyzgrid -from evennia.utils.utils import list_to_string, class_from_module, make_iter +from evennia.utils import ansi +from evennia.utils.utils import list_to_string, class_from_module, delay COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS) +# temporary store of goto/path data when using the auto-stepper +PathData = namedtuple("PathData", ("target", "xymap", "directions", "step_sequence", "task")) + + class CmdXYZTeleport(building.CmdTeleport): """ teleport object to another location @@ -163,17 +169,19 @@ class CmdXYZOpen(building.CmdOpen): class CmdGoto(COMMAND_DEFAULT_CLASS): """ - Go to a named location in this area. + Go to a named location in this area via the shortest path. Usage: - goto - get path and start walking - path - just check the path - goto - abort current goto - path - show current path + path - find shortest path to target location (don't move) + goto - auto-move to target location, using shortest path + path - show current target location and shortest path + goto - abort current goto, otherwise show current path + path clear - clear current path - This will find the shortest route to a location in your current area and - start automatically walk you there. Builders can also specify a specific grid - coordinate (X,Y). + Finds the shortest route to a location in your current area and + can then automatically walk you there. + + Builders can optionally specify a specific grid coordinate (X,Y) to go to. """ key = "goto" @@ -181,6 +189,9 @@ class CmdGoto(COMMAND_DEFAULT_CLASS): help_category = "General" locks = "cmd:all()" + # how quickly to step (seconds) + auto_step_delay = 2 + def _search_by_xyz(self, inp, xyz_start): inp = inp.strip("()") X, Y = inp.split(",", 2) @@ -198,27 +209,152 @@ class CmdGoto(COMMAND_DEFAULT_CLASS): candidates = list(XYZRoom.objects.filter_xyz(xyz=('*', '*', Z))) return self.caller.search(inp, candidates=candidates) + def _auto_step(self, caller, session, target=None, + xymap=None, directions=None, step_sequence=None, step=True): + + path_data = caller.ndb.xy_path_data + + if target: + # start/replace an old path if we provide the data for it + if path_data and path_data.task and path_data.task.active(): + # stop any old task in its tracks + path_data.task.cancel() + path_data = caller.ndb.xy_path_data = PathData( + target=target, xymap=xymap, directions=directions, + step_sequence=step_sequence, task=None) + + if step and path_data: + + step_sequence = path_data.step_sequence + + try: + direction = path_data.directions.pop(0) + current_node = path_data.step_sequence.pop(0) + first_link = path_data.step_sequence.pop(0) + except IndexError: + caller.msg("Target reached.", session=session) + caller.ndb.xy_path_data = None + return + + # verfy our current location against the expected location + expected_xyz = (current_node.X, current_node.Y, current_node.Z) + location = caller.location + try: + xyz_start = location.xyz + except AttributeError: + caller.ndb.xy_path_data = None + caller.msg("Goto aborted - outside of area.", session=session) + return + + if xyz_start != expected_xyz: + # we are not where we expected to be (maybe the user moved + # manually) - we must recalculate the path to target + caller.msg("Path changed - recalculating ('goto' to abort)", session=session) + + try: + xyz_end = path_data.target.xyz + except AttributeError: + caller.ndb.xy_path_data = None + caller.msg("Goto aborted - target outside of area.", session=session) + return + + if xyz_start[2] != xyz_end[2]: + # can't go to another map + caller.ndb.xy_path_data = None + caller.msg("Goto aborted - target outside of area.", session=session) + return + + # recalculate path + xy_start = xyz_start[:2] + xy_end = xyz_end[:2] + directions, step_sequence = path_data.xymap.get_shortest_path(xy_start, xy_end) + + # try again with this path, rebuilding the data + try: + direction = directions.pop(0) + current_node = step_sequence.pop(0) + first_link = step_sequence.pop(0) + except IndexError: + caller.msg("Target reached.", session=session) + caller.ndb.xy_path_data = None + return + + path_data = caller.ndb.xy_path_data = PathData( + target=path_data.target, + xymap=path_data.xymap, + directions=directions, + step_sequence=step_sequence, + task=None + ) + + # pop any extra links up until the next node - these are + # not useful when dealing with exits + while step_sequence: + if hasattr(step_sequence[0], "node_index"): + break + step_sequence.pop(0) + + # the exit name does not need to be the same as the cardinal direction! + exit_name, *_ = first_link.spawn_aliases.get( + direction, current_node.direction_spawn_defaults.get(direction, ('unknown', ))) + + if not caller.search(exit_name): + # extra safety measure to avoid trying to walk over and over + # if there's something wrong with the exit's name + caller.msg(f"No exit '{exit_name}' found at current location. Aborting goto.") + caller.ndb.xy_path_data = None + return + + # do the actual move - we use the command to allow for more obvious overrides + caller.execute_cmd(exit_name, session=session) + + # namedtuples are unmutables, so we recreate and store + # with the new task + caller.ndb.xy_path_data = PathData( + target=path_data.target, + xymap=path_data.xymap, + directions=path_data.directions, + step_sequence=path_data.step_sequence, + task=delay(self.auto_step_delay, self._auto_step, caller, session) + ) + def func(self): """ Implement command """ caller = self.caller - - current_target, *current_path = make_iter(caller.ndb.xy_current_goto) goto_mode = self.cmdname == 'goto' + # check if we have an existing path + path_data = caller.ndb.xy_path_data + if not self.args: - if current_target: + if path_data: + target_name = path_data.target.get_display_name(caller) + task = path_data.task if goto_mode: - caller.ndb.xy_current_goto_target = None - caller.msg("Aborted goto.") - else: - caller.msg(f"Remaining steps: {list_to_string(current_path)}") + if task and task.active(): + task.cancel() + caller.msg(f"Aborted auto-walking to {target_name}.") + return + # goto/path-command will show current path + current_path = list_to_string( + [f"|w{step}|n" for step in path_data.directions]) + moving = "(moving)" if task and task.active() else "" + caller.msg(f"Path to {target_name}{moving}: {current_path}") else: - caller.msg("Usage: goto ") + caller.msg("Usage: goto|path []") return + if not goto_mode and self.args == "clear" and path_data: + # in case there is a target location 'clear', this is only + # used if path data already exists. + caller.ndb.xy_path_data = None + caller.msg("Cleared goto-path.") + return + + # find target xyzgrid = get_xyzgrid() try: xyz_start = caller.location.xyz @@ -247,18 +383,61 @@ class CmdGoto(COMMAND_DEFAULT_CLASS): # we only need the xy coords once we have the map xy_start = xyz_start[:2] xy_end = xyz_end[:2] - shortest_path, _ = xymap.get_shortest_path(xy_start, xy_end) + directions, step_sequence = xymap.get_shortest_path(xy_start, xy_end) - caller.msg(f"There are {len(shortest_path)} steps to {target.get_display_name(caller)}: " - f"|w{list_to_string(shortest_path, endsep='|nand finally|w')}|n") + caller.msg(f"There are {len(directions)} steps to {target.get_display_name(caller)}: " + f"|w{list_to_string(directions, endsep='|n, and finally|w')}|n") - # store for use by the return_appearance hook on the XYZRoom - caller.ndb.xy_current_goto = (xy_end, shortest_path) + # create data for display and start stepping if we used goto + self._auto_step(caller, self.session, target=target, xymap=xymap, + directions=directions, step_sequence=step_sequence, step=goto_mode) - if self.cmdname == "goto": - # start actually walking right away - self.msg("Walking ... eventually") - pass + +class CmdMap(COMMAND_DEFAULT_CLASS): + """ + Show a map of an area + + Usage: + map [Zcoord] + map list + + This is a builder-command. + + """ + key = "map" + locks = "cmd:perm(Builders)" + + def func(self): + """Implement command""" + + xyzgrid = get_xyzgrid() + Z = None + + if not self.args: + # show current area's map + location = self.caller.location + try: + xyz = location.xyz + except AttributeError: + self.caller.msg("Your current location is not on the grid.") + return + Z = xyz[2] + + elif self.args.strip().lower() == "list": + xymaps = "\n ".join(str(repr(xymap)) for xymap in xyzgrid.all_maps()) + self.caller.msg(f"Maps (Z coords) on the grid:\n |w{xymaps}") + return + + else: + Z = self.args + + xymap = xyzgrid.get_map(Z) + if not xymap: + self.caller.msg(f"XYMap '{Z}' is not found on the grid. Try 'map list' to see " + "available maps/Zcoords.") + return + + self.caller.msg(ansi.raw(xymap.mapstring)) class XYZGridCmdSet(CmdSet): @@ -272,3 +451,4 @@ class XYZGridCmdSet(CmdSet): self.add(CmdXYZTeleport()) self.add(CmdXYZOpen()) self.add(CmdGoto()) + self.add(CmdMap()) diff --git a/evennia/contrib/xyzgrid/map_legend.py b/evennia/contrib/xyzgrid/map_legend.py index 2d8e49d045..123f4f4afa 100644 --- a/evennia/contrib/xyzgrid/map_legend.py +++ b/evennia/contrib/xyzgrid/map_legend.py @@ -258,6 +258,29 @@ class MapNode: """ return self.X, self.Y, self.Z + def get_exit_spawn_name(self, direction, return_aliases=True): + + """ + Retrieve the spawn name for the exit being created by this link. + + Args: + direction (str): The cardinal direction (n,ne etc) the want the + exit name/aliases for. + return_aliases (bool, optional): Also return all aliases. + + Returns: + str or tuple: The key of the spawned exit, or a tuple (key, alias, alias, ...) + + """ + key, *aliases = ( + self.first_links[direction] + .spawn_aliases.get( + direction, self.direction_spawn_defaults.get( + direction, ('unknown', )))) + if return_aliases: + return (key, *aliases) + return key + def spawn(self): """ Build an actual in-game room from this node. @@ -327,11 +350,7 @@ class MapNode: maplinks = {} for direction, link in self.first_links.items(): - key, *aliases = ( - link.spawn_aliases.get(direction, ('unknown',)) - if link.spawn_aliases - else self.direction_spawn_defaults.get(direction, ('unknown',)) - ) + key, *aliases = self.get_exit_spawn_name(direction) if not link.prototype.get('prototype_key'): # generate a deterministic prototype_key if it doesn't exist link.prototype['prototype_key'] = self.generate_prototype_key() @@ -495,9 +514,11 @@ class MapLink: 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. - - `spawn_aliases` (list): A list of [key, alias, alias, ...] for the node to use when spawning - exits from this link. If not given, a sane set of defaults ((north, n) etc) will be used. This - is required if you use any custom directions outside of the cardinal directions + up/down. + - `spawn_aliases` (dict): A mapping {direction: (key, alias, alias, ...) to use when spawning + actual exits from this link. If not given, a sane set of defaults (n=(north, n) etc) will be + used. This is required if you use any custom directions outside of the cardinal directions + + up/down. The exit's key (useful for auto-walk) is usually retrieved by calling + `node.get_exit_spawn_name(direction)` """ # symbol for identifying this link on the map @@ -535,10 +556,11 @@ class MapLink: interrupt_path = False # prototype for the first link out of a node. prototype = None - # used for spawning, if the exit prototype doesn't contain an explicit key. - # if neither that nor this is not given, the central node's direction_aliases will be used. - # the first element of this list is the key, the others are the aliases. - spawn_aliases = [] + # used for spawning and maps {direction: (key, alias, alias, ...) for use for the exits spawned + # in this direction. Used unless the exit's prototype contain an explicit key - then that will + # take precedence. If neither that nor this is not given, sane defaults ('n'=('north','n'), etc) + # will be used. + spawn_aliases = {} def __init__(self, x, y, Z, xymap=None): """ diff --git a/evennia/contrib/xyzgrid/xymap.py b/evennia/contrib/xyzgrid/xymap.py index fcea6dc0d9..d0f67c9fe1 100644 --- a/evennia/contrib/xyzgrid/xymap.py +++ b/evennia/contrib/xyzgrid/xymap.py @@ -717,7 +717,7 @@ class XYMap: Returns: tuple: Two lists, first containing the list of directions as strings (n, ne etc) and - the second is a mixed list of MapNodes and string-directions in a sequence describing + the second is a mixed list of MapNodes and all MapLinks in a sequence describing the full path including the start- and end-node. """ @@ -909,7 +909,7 @@ class XYMap: for node_or_link in path[1:]: if hasattr(node_or_link, "node_index"): nsteps += 1 - if nsteps >= maxstep: + if nsteps > maxstep: break # don't decorate current (character?) location ix, iy = node_or_link.x, node_or_link.y diff --git a/evennia/contrib/xyzgrid/xyzroom.py b/evennia/contrib/xyzgrid/xyzroom.py index 7b0614bc93..46d2706d5a 100644 --- a/evennia/contrib/xyzgrid/xyzroom.py +++ b/evennia/contrib/xyzgrid/xyzroom.py @@ -430,14 +430,16 @@ class XYZRoom(DefaultRoom): elif map_align == 'c': map_indent = max(0, (display_width - map_width) // 2) - goto_target, *current_path = make_iter(looker.ndb.xy_current_goto) + # data set by the goto/path-command, for displaying the shortest path + path_data = looker.ndb.xy_path_data + target_xy = path_data.target.xyz[:2] if path_data else None # get visual range display from map map_display = xymap.get_visual_range( (xyz[0], xyz[1]), dist=visual_range, mode=map_mode, - target=goto_target, + target=target_xy, target_path_style="|y{display_symbol}|n", character=f"|g{character_symbol}|n", max_size=(display_width, None),