diff --git a/evennia/contrib/grid/ingame_map_display/README.md b/evennia/contrib/grid/ingame_map_display/README.md new file mode 100644 index 0000000000..3e52089cfd --- /dev/null +++ b/evennia/contrib/grid/ingame_map_display/README.md @@ -0,0 +1,51 @@ +# Basic Map + +Contribution - helpme 2022 + +This adds an ascii `map` to a given room which can be viewed with the `map` command. +You can easily alter it to add special characters, room colors etc. The map shown is +dynamically generated on use, and supports all compass directions and up/down. Other +directions are ignored. + +If you don't expect the map to be updated frequently, you could choose to save the +calculated map as a .ndb value on the room and render that instead of running mapping +calculations anew each time. + +## Installation: + +Adding the `MapDisplayCmdSet` to the default character cmdset will add the `map` command. + +Specifically, in `mygame/commands/default_cmdsets.py`: + +```python +... +from evennia.contrib.grid.ingame_map_display import MapDisplayCmdSet # <--- + +class CharacterCmdset(default_cmds.Character_CmdSet): + ... + def at_cmdset_creation(self): + ... + self.add(MapDisplayCmdSet) # <--- + +``` + +Then `reload` to make the new commands available. + +## Settings: + +In order to change your default map size, you can add to `mygame/server/settings.py`: + +```python +BASIC_MAP_SIZE = 5 # This changes the default map width/height. + +``` + +## Features: + +### ASCII map (and evennia supports UTF-8 characters and even emojis) + +This produces an ASCII map for players of configurable size. + +### New command + +- `CmdMap` - view the map diff --git a/evennia/contrib/grid/ingame_map_display/__init__.py b/evennia/contrib/grid/ingame_map_display/__init__.py new file mode 100644 index 0000000000..8c3b92b4ec --- /dev/null +++ b/evennia/contrib/grid/ingame_map_display/__init__.py @@ -0,0 +1,6 @@ +""" +Mapbuilder - helpme 2022 + +""" + +from .ingame_map_display import MapDisplayCmdSet # noqa diff --git a/evennia/contrib/grid/ingame_map_display/ingame_map_display.py b/evennia/contrib/grid/ingame_map_display/ingame_map_display.py new file mode 100644 index 0000000000..b3ef013b5f --- /dev/null +++ b/evennia/contrib/grid/ingame_map_display/ingame_map_display.py @@ -0,0 +1,323 @@ +""" +Basic Map - helpme 2022 + +This adds an ascii `map` to a given room which can be viewed with the `map` command. +You can easily alter it to add special characters, room colors etc. The map shown is +dynamically generated on use, and supports all compass directions and up/down. Other +directions are ignored. + +If you don't expect the map to be updated frequently, you could choose to save the +calculated map as a .ndb value on the room and render that instead of running mapping +calculations anew each time. + +An example map: +``` + | + -[-]- + | + | +-[-]--[-]--[-]--[-] + | | | | + | | | + -[-]--[-] [-] + | \/ | | + \ | /\ | + -[-]--[-] +``` + +Installation: + +Adding the `MapDisplayCmdSet` to the default character cmdset will add the `map` command. + +Specifically, in `mygame/commands/default_cmdsets.py`: + +``` +... +from evennia.contrib.grid.ingame_map_display import MapDisplayCmdSet # <--- + +class CharacterCmdset(default_cmds.Character_CmdSet): + ... + def at_cmdset_creation(self): + ... + self.add(MapDisplayCmdSet) # <--- + +``` + +Then `reload` to make the new commands available. + +Additional Settings: + +In order to change your default map size, you can add to `mygame/server/settings.py`: + +BASIC_MAP_SIZE = 5 + +This changes the default map width/height. 2-5 for most clients is sensible. + +If you don't want the player to be able to specify the size of the map, ignore any +arguments passed into the Map command. +""" +import time +from django.conf import settings +from evennia import CmdSet +from evennia.commands.default.muxcommand import MuxCommand + +_BASIC_MAP_SIZE = settings.BASIC_MAP_SIZE if hasattr(settings, 'BASIC_MAP_SIZE') else 2 +_MAX_MAP_SIZE = settings.BASIC_MAP_SIZE if hasattr(settings, 'MAX_MAP_SIZE') else 10 + +# _COMPASS_DIRECTIONS specifies which way to move the pointer on the x/y axes and what characters to use to depict the exits on the map. +_COMPASS_DIRECTIONS = { + 'north': (0, -3, ' | '), + 'south': (0, 3, ' | '), + 'east': (3, 0, '-'), + 'west': (-3, 0, '-'), + 'northeast': (3, -3, '/'), + 'northwest': (-3, -3, '\\'), + 'southeast': (3, 3, '\\'), + 'southwest': (-3, 3, '/'), + 'up': (0, 0, '^'), + 'down': (0, 0, 'v') +} + + +class Map(object): + def __init__(self, caller, size=_BASIC_MAP_SIZE, location=None): + """ + Initializes the map. + + Args: + caller (object): Any object, though generally a puppeted character. + size (int): The seed size of the map, which will be multiplied to get the final grid size. + location (object): The location at the map's center (will default to caller.location if none provided). + """ + self.start_time = time.time() + self.caller = caller + self.max_width = int(size * 2 + 1) * 5 # This must be an odd number + self.max_length = int(size * 2 + 1) * 3 # This must be an odd number + self.has_mapped = {} + self.curX = None + self.curY = None + self.size = size + self.location = location or caller.location + + def create_grid(self): + """ + Create the empty grid for the map based on the configured size + + Returns: + list: The created grid, a list of lists. + """ + board = [] + for row in range(self.max_length): + board.append([]) + for column in range(int(self.max_width/5)): + board[row].extend([' ', ' ', ' ']) + return board + + def exit_name_as_ordinal(self, ex): + """ + Get the exit name as a compass direction if possible + + Args: + ex (Exit): The current exit being mapped. + Returns: + string: The exit name as a compass direction or an empty string. + """ + exit_name = ex.name + if exit_name not in _COMPASS_DIRECTIONS: + compass_aliases = [direction in ex.aliases.all() for direction in _COMPASS_DIRECTIONS.keys()] + if compass_aliases[0]: + exit_name = compass_aliases[0] + if exit_name not in _COMPASS_DIRECTIONS: + return '' + return exit_name + + def update_pos(self, room, exit_name): + """ + Update the position pointer. + + Args: + room (Room): The current location. + exit_name (str): The name of the exit to to use in this room. This must + be a valid compass direction, or an error will be raised. + Raises: + KeyError: If providing a non-compass exit name. + """ + # Update the pointer + self.curX, self.curY = self.has_mapped[room][0], self.has_mapped[room][1] + + # Move the pointer depending on which direction the exit lies + # exit_name has already been validated as an ordinal direction at this point + self.curY += _COMPASS_DIRECTIONS[exit_name][0] + self.curX += _COMPASS_DIRECTIONS[exit_name][1] + + def has_drawn(self, room): + """ + Checks if the given room has already been drawn or not + + Args: + room (Room): Room to check. + Returns: + bool: Whether or not the room has been drawn. + """ + return True if room in self.has_mapped.keys() else False + + def draw_room_on_map(self, room, max_distance): + """ + Draw the room and its exits on the map recursively + + Args: + room (Room): The room to draw out. + max_distance (int): How extensive the map is. + """ + self.draw(room) + self.draw_exits(room) + + if max_distance == 0: + return + + # Check if the caller has access to the room in question. If not, don't draw it. + # Additionally, if the name of the exit is not ordinal but an alias of it is, use that. + for ex in [x for x in room.exits if x.access(self.caller, "traverse")]: + ex_name = self.exit_name_as_ordinal(ex) + if not ex_name or ex_name in ['up', 'down']: + continue + if self.has_drawn(ex.destination): + continue + + self.update_pos(room, ex_name.lower()) + self.draw_room_on_map(ex.destination, max_distance - 1) + + def draw_exits(self, room): + """ + Draw a given room's exit paths + + Args: + room (Room): The room to draw exits of. + """ + x, y = self.curX, self.curY + for ex in room.exits: + ex_name = self.exit_name_as_ordinal(ex) + if not ex_name: + continue + + ex_character = _COMPASS_DIRECTIONS[ex_name][2] + delta_x = int(_COMPASS_DIRECTIONS[ex_name][1]/3) + delta_y = int(_COMPASS_DIRECTIONS[ex_name][0]/3) + + # Make modifications if the exit has BOTH up and down exits + if ex_name == 'up': + if 'v' in self.grid[x][y]: + self.render_room(room, x, y, p1='^', p2='v') + else: + self.render_room(room, x, y, here='^') + elif ex_name == 'down': + if '^' in self.grid[x][y]: + self.render_room(room, x, y, p1='^', p2='v') + else: + self.render_room(room, x, y, here='v') + else: + self.grid[x + delta_x][y + delta_y] = ex_character + + def draw(self, room): + """ + Draw the map starting from a given room and add it to the cache of mapped rooms + + Args: + room (Room): The room to render. + """ + # draw initial caller location on map first! + if room == self.location: + self.start_loc_on_grid(room) + self.has_mapped[room] = [self.curX, self.curY] + else: + # map all other rooms + self.has_mapped[room] = [self.curX, self.curY] + self.render_room(room, self.curX, self.curY) + + def render_room(self, room, x, y, p1='[', p2=']', here=None): + """ + Draw a given room with ascii characters + + Args: + room (Room): The room to render. + x (int): The x-value of the room on the grid (horizontally, east/west). + y (int): The y-value of the room on the grid (vertically, north/south). + p1 (str): The first character of the 3-character room depiction. + p2 (str): The last character of the 3-character room depiction. + here (str): Defaults to none, a special character depicting the room. + """ + # Note: This is where you would set colors, symbols etc. + # Render the room + you = list("[ ]") + + you[0] = f"{p1}|n" + you[1] = f"{here if here else you[1]}" + if room == self.caller.location: + you[1] = '|[x|co|n' # Highlight the location you are currently in + you[2] = f"{p2}|n" + + self.grid[x][y] = "".join(you) + + def start_loc_on_grid(self, room): + """ + Set the starting location on the grid based on the maximum width and length + + Args: + room (Room): The room to begin with. + """ + x = int((self.max_width * 0.6 - 1) / 2) + y = int((self.max_length - 1) / 2) + + self.render_room(room, x, y) + self.curX, self.curY = x, y + + def show_map(self, debug=False): + """ + Create and show the map, piecing it all together in the end + + Args: + debug (bool): Whether or not to return the time taken to build the map. + """ + map_string = "" + self.grid = self.create_grid() + self.draw_room_on_map(self.location, self.size) + + for row in self.grid: + map_row = "".join(row) + if map_row.strip() != "": + map_string += f"{map_row}\n" + + elapsed = time.time() - self.start_time + if debug: + map_string += f"\nTook {elapsed}ms to render the map.\n" + + return "%s" % map_string + + +class CmdMap(MuxCommand): + """ + Check the local map around you. + + Usage: map (optional size) + """ + key = "map" + + def func(self): + size = _BASIC_MAP_SIZE + max_size = _MAX_MAP_SIZE + if self.args.isnumeric(): + size = min(max_size, int(self.args)) + + # You can run show_map(debug=True) to see how long it takes. + map_here = Map(self.caller, size=size).show_map() + self.caller.msg((map_here, {"type": "map"})) + + +# CmdSet for easily install all commands +class MapDisplayCmdSet(CmdSet): + """ + The map command. + """ + + def at_cmdset_creation(self): + self.add(CmdMap) diff --git a/evennia/contrib/grid/ingame_map_display/tests.py b/evennia/contrib/grid/ingame_map_display/tests.py new file mode 100644 index 0000000000..9d505575c5 --- /dev/null +++ b/evennia/contrib/grid/ingame_map_display/tests.py @@ -0,0 +1,35 @@ +""" +Tests of ingame_map_display. + +""" + + +from evennia.commands.default.tests import BaseEvenniaCommandTest +from evennia.utils.create import create_object +from typeclasses import rooms, exits +from . import ingame_map_display + + +class TestIngameMap(BaseEvenniaCommandTest): + """ + Test the ingame map display by building two rooms and checking their connections are found + + Expected output: + [ ]--[ ] + """ + def setUp(self): + super().setUp() + self.west_room = create_object(rooms.Room, key="Room 1") + self.east_room = create_object(rooms.Room, key="Room 2") + create_object(exits.Exit, key="east", aliases=["e"], location=self.west_room, destination=self.east_room) + create_object(exits.Exit, key="west", aliases=["w"], location=self.east_room, destination=self.west_room) + + def test_west_room_map_room(self): + self.char1.location = self.west_room + map_here = ingame_map_display.Map(self.char1).show_map() + self.assertEqual(map_here.strip(), '[|n|[x|co|n]|n--[|n ]|n') + + def test_east_room_map_room(self): + self.char1.location = self.east_room + map_here = ingame_map_display.Map(self.char1).show_map() + self.assertEqual(map_here.strip(), '[|n ]|n--[|n|[x|co|n]|n') \ No newline at end of file