Start refactor contrib folder

This commit is contained in:
Griatch 2021-12-18 11:32:34 +01:00
parent 7f0d314e7f
commit f5f75bd04d
107 changed files with 34 additions and 2 deletions

View file

@ -0,0 +1,3 @@
# Grid contribs
General contribs dealing with the game-world grid, maps and rooms.

View file

@ -0,0 +1,593 @@
"""
Extended Room
Evennia Contribution - Griatch 2012, vincent-lg 2019
This is an extended Room typeclass for Evennia. It is supported
by an extended `Look` command and an extended `desc` command, also
in this module.
Features:
1) Time-changing description slots
This allows to change the full description text the room shows
depending on larger time variations. Four seasons (spring, summer,
autumn and winter) are used by default. The season is calculated
on-demand (no Script or timer needed) and updates the full text block.
There is also a general description which is used as fallback if
one or more of the seasonal descriptions are not set when their
time comes.
An updated `desc` command allows for setting seasonal descriptions.
The room uses the `evennia.utils.gametime.GameTime` global script. This is
started by default, but if you have deactivated it, you need to
supply your own time keeping mechanism.
2) In-description changing tags
Within each seasonal (or general) description text, you can also embed
time-of-day dependent sections. Text inside such a tag will only show
during that particular time of day. The tags looks like `<timeslot> ...
</timeslot>`. By default there are four timeslots per day - morning,
afternoon, evening and night.
3) Details
The Extended Room can be "detailed" with special keywords. This makes
use of a special `Look` command. Details are "virtual" targets to look
at, without there having to be a database object created for it. The
Details are simply stored in a dictionary on the room and if the look
command cannot find an object match for a `look <target>` command it
will also look through the available details at the current location
if applicable. The `@detail` command is used to change details.
4) Extra commands
CmdExtendedRoomLook - look command supporting room details
CmdExtendedRoomDesc - desc command allowing to add seasonal descs,
CmdExtendedRoomDetail - command allowing to manipulate details in this room
as well as listing them
CmdExtendedRoomGameTime - A simple `time` command, displaying the current
time and season.
Installation/testing:
Adding the `ExtendedRoomCmdset` to the default character cmdset will add all
new commands for use.
In more detail, in mygame/commands/default_cmdsets.py:
```
...
from evennia.contrib import extended_room # <-new
class CharacterCmdset(default_cmds.Character_CmdSet):
...
def at_cmdset_creation(self):
...
self.add(extended_room.ExtendedRoomCmdSet) # <-new
```
Then reload to make the bew commands available. Note that they only work
on rooms with the typeclass `ExtendedRoom`. Create new rooms with the right
typeclass or use the `typeclass` command to swap existing rooms.
"""
import datetime
import re
from django.conf import settings
from evennia import DefaultRoom
from evennia import gametime
from evennia import default_cmds
from evennia import utils
from evennia import CmdSet
# error return function, needed by Extended Look command
_AT_SEARCH_RESULT = utils.variable_from_module(*settings.SEARCH_AT_RESULT.rsplit(".", 1))
# regexes for in-desc replacements
RE_MORNING = re.compile(r"<morning>(.*?)</morning>", re.IGNORECASE)
RE_AFTERNOON = re.compile(r"<afternoon>(.*?)</afternoon>", re.IGNORECASE)
RE_EVENING = re.compile(r"<evening>(.*?)</evening>", re.IGNORECASE)
RE_NIGHT = re.compile(r"<night>(.*?)</night>", re.IGNORECASE)
# this map is just a faster way to select the right regexes (the first
# regex in each tuple will be parsed, the following will always be weeded out)
REGEXMAP = {
"morning": (RE_MORNING, RE_AFTERNOON, RE_EVENING, RE_NIGHT),
"afternoon": (RE_AFTERNOON, RE_MORNING, RE_EVENING, RE_NIGHT),
"evening": (RE_EVENING, RE_MORNING, RE_AFTERNOON, RE_NIGHT),
"night": (RE_NIGHT, RE_MORNING, RE_AFTERNOON, RE_EVENING),
}
# set up the seasons and time slots. This assumes gametime started at the
# beginning of the year (so month 1 is equivalent to January), and that
# one CAN divide the game's year into four seasons in the first place ...
MONTHS_PER_YEAR = 12
SEASONAL_BOUNDARIES = (3 / 12.0, 6 / 12.0, 9 / 12.0)
HOURS_PER_DAY = 24
DAY_BOUNDARIES = (0, 6 / 24.0, 12 / 24.0, 18 / 24.0)
# implements the Extended Room
class ExtendedRoom(DefaultRoom):
"""
This room implements a more advanced `look` functionality depending on
time. It also allows for "details", together with a slightly modified
look command.
"""
def at_object_creation(self):
"""Called when room is first created only."""
self.db.spring_desc = ""
self.db.summer_desc = ""
self.db.autumn_desc = ""
self.db.winter_desc = ""
# the general desc is used as a fallback if a seasonal one is not set
self.db.general_desc = ""
# will be set dynamically. Can contain raw timeslot codes
self.db.raw_desc = ""
# this will be set dynamically at first look. Parsed for timeslot codes
self.db.desc = ""
# these will be filled later
self.ndb.last_season = None
self.ndb.last_timeslot = None
# detail storage
self.db.details = {}
def get_time_and_season(self):
"""
Calculate the current time and season ids.
"""
# get the current time as parts of year and parts of day.
# we assume a standard calendar here and use 24h format.
timestamp = gametime.gametime(absolute=True)
# note that fromtimestamp includes the effects of server time zone!
datestamp = datetime.datetime.fromtimestamp(timestamp)
season = float(datestamp.month) / MONTHS_PER_YEAR
timeslot = float(datestamp.hour) / HOURS_PER_DAY
# figure out which slots these represent
if SEASONAL_BOUNDARIES[0] <= season < SEASONAL_BOUNDARIES[1]:
curr_season = "spring"
elif SEASONAL_BOUNDARIES[1] <= season < SEASONAL_BOUNDARIES[2]:
curr_season = "summer"
elif SEASONAL_BOUNDARIES[2] <= season < 1.0 + SEASONAL_BOUNDARIES[0]:
curr_season = "autumn"
else:
curr_season = "winter"
if DAY_BOUNDARIES[0] <= timeslot < DAY_BOUNDARIES[1]:
curr_timeslot = "night"
elif DAY_BOUNDARIES[1] <= timeslot < DAY_BOUNDARIES[2]:
curr_timeslot = "morning"
elif DAY_BOUNDARIES[2] <= timeslot < DAY_BOUNDARIES[3]:
curr_timeslot = "afternoon"
else:
curr_timeslot = "evening"
return curr_season, curr_timeslot
def replace_timeslots(self, raw_desc, curr_time):
"""
Filter so that only time markers `<timeslot>...</timeslot>` of
the correct timeslot remains in the description.
Args:
raw_desc (str): The unmodified description.
curr_time (str): A timeslot identifier.
Returns:
description (str): A possibly moified description.
"""
if raw_desc:
regextuple = REGEXMAP[curr_time]
raw_desc = regextuple[0].sub(r"\1", raw_desc)
raw_desc = regextuple[1].sub("", raw_desc)
raw_desc = regextuple[2].sub("", raw_desc)
return regextuple[3].sub("", raw_desc)
return raw_desc
def return_detail(self, key):
"""
This will attempt to match a "detail" to look for in the room.
Args:
key (str): A detail identifier.
Returns:
detail (str or None): A detail matching the given key.
Notes:
A detail is a way to offer more things to look at in a room
without having to add new objects. For this to work, we
require a custom `look` command that allows for `look
<detail>` - the look command should defer to this method on
the current location (if it exists) before giving up on
finding the target.
Details are not season-sensitive, but are parsed for timeslot
markers.
"""
try:
detail = self.db.details.get(key.lower(), None)
except AttributeError:
# this happens if no attribute details is set at all
return None
if detail:
season, timeslot = self.get_time_and_season()
detail = self.replace_timeslots(detail, timeslot)
return detail
return None
def set_detail(self, detailkey, description):
"""
This sets a new detail, using an Attribute "details".
Args:
detailkey (str): The detail identifier to add (for
aliases you need to add multiple keys to the
same description). Case-insensitive.
description (str): The text to return when looking
at the given detailkey.
"""
if self.db.details:
self.db.details[detailkey.lower()] = description
else:
self.db.details = {detailkey.lower(): description}
def del_detail(self, detailkey, description):
"""
Delete a detail.
The description is ignored.
Args:
detailkey (str): the detail to remove (case-insensitive).
description (str, ignored): the description.
The description is only included for compliance but is completely
ignored. Note that this method doesn't raise any exception if
the detail doesn't exist in this room.
"""
if self.db.details and detailkey.lower() in self.db.details:
del self.db.details[detailkey.lower()]
def return_appearance(self, looker, **kwargs):
"""
This is called when e.g. the look command wants to retrieve
the description of this object.
Args:
looker (Object): The object looking at us.
**kwargs (dict): Arbitrary, optional arguments for users
overriding the call (unused by default).
Returns:
description (str): Our description.
"""
# ensures that our description is current based on time/season
self.update_current_description()
# run the normal return_appearance method, now that desc is updated.
return super(ExtendedRoom, self).return_appearance(looker, **kwargs)
def update_current_description(self):
"""
This will update the description of the room if the time or season
has changed since last checked.
"""
update = False
# get current time and season
curr_season, curr_timeslot = self.get_time_and_season()
# compare with previously stored slots
last_season = self.ndb.last_season
last_timeslot = self.ndb.last_timeslot
if curr_season != last_season:
# season changed. Load new desc, or a fallback.
new_raw_desc = self.attributes.get("%s_desc" % curr_season)
if new_raw_desc:
raw_desc = new_raw_desc
else:
# no seasonal desc set. Use fallback
raw_desc = self.db.general_desc or self.db.desc
self.db.raw_desc = raw_desc
self.ndb.last_season = curr_season
update = True
if curr_timeslot != last_timeslot:
# timeslot changed. Set update flag.
self.ndb.last_timeslot = curr_timeslot
update = True
if update:
# if anything changed we have to re-parse
# the raw_desc for time markers
# and re-save the description again.
self.db.desc = self.replace_timeslots(self.db.raw_desc, curr_timeslot)
# Custom Look command supporting Room details. Add this to
# the Default cmdset to use.
class CmdExtendedRoomLook(default_cmds.CmdLook):
"""
look
Usage:
look
look <obj>
look <room detail>
look *<account>
Observes your location, details at your location or objects in your vicinity.
"""
def func(self):
"""
Handle the looking - add fallback to details.
"""
caller = self.caller
args = self.args
if args:
looking_at_obj = caller.search(
args,
candidates=caller.location.contents + caller.contents,
use_nicks=True,
quiet=True,
)
if not looking_at_obj:
# no object found. Check if there is a matching
# detail at location.
location = caller.location
if (
location
and hasattr(location, "return_detail")
and callable(location.return_detail)
):
detail = location.return_detail(args)
if detail:
# we found a detail instead. Show that.
caller.msg(detail)
return
# no detail found. Trigger delayed error messages
_AT_SEARCH_RESULT(looking_at_obj, caller, args, quiet=False)
return
else:
# we need to extract the match manually.
looking_at_obj = utils.make_iter(looking_at_obj)[0]
else:
looking_at_obj = caller.location
if not looking_at_obj:
caller.msg("You have no location to look at!")
return
if not hasattr(looking_at_obj, "return_appearance"):
# this is likely due to us having an account instead
looking_at_obj = looking_at_obj.character
if not looking_at_obj.access(caller, "view"):
caller.msg("Could not find '%s'." % args)
return
# get object's appearance
caller.msg(looking_at_obj.return_appearance(caller))
# the object's at_desc() method.
looking_at_obj.at_desc(looker=caller)
# Custom build commands for setting seasonal descriptions
# and detailing extended rooms.
class CmdExtendedRoomDesc(default_cmds.CmdDesc):
"""
`desc` - describe an object or room.
Usage:
desc[/switch] [<obj> =] <description>
Switches for `desc`:
spring - set description for <season> in current room.
summer
autumn
winter
Sets the "desc" attribute on an object. If an object is not given,
describe the current room.
You can also embed special time markers in your room description, like this:
```
<night>In the darkness, the forest looks foreboding.</night>.
```
Text marked this way will only display when the server is truly at the given
timeslot. The available times are night, morning, afternoon and evening.
Note that seasons and time-of-day slots only work on rooms in this
version of the `desc` command.
"""
aliases = ["describe"]
switch_options = () # Inherits from default_cmds.CmdDesc, but unused here
def reset_times(self, obj):
"""By deleteting the caches we force a re-load."""
obj.ndb.last_season = None
obj.ndb.last_timeslot = None
def func(self):
"""Define extended command"""
caller = self.caller
location = caller.location
if not self.args:
if location:
string = "|wDescriptions on %s|n:\n" % location.key
string += " |wspring:|n %s\n" % location.db.spring_desc
string += " |wsummer:|n %s\n" % location.db.summer_desc
string += " |wautumn:|n %s\n" % location.db.autumn_desc
string += " |wwinter:|n %s\n" % location.db.winter_desc
string += " |wgeneral:|n %s" % location.db.general_desc
caller.msg(string)
return
if self.switches and self.switches[0] in ("spring", "summer", "autumn", "winter"):
# a seasonal switch was given
if self.rhs:
caller.msg("Seasonal descs only work with rooms, not objects.")
return
switch = self.switches[0]
if not location:
caller.msg("No location was found!")
return
if switch == "spring":
location.db.spring_desc = self.args
elif switch == "summer":
location.db.summer_desc = self.args
elif switch == "autumn":
location.db.autumn_desc = self.args
elif switch == "winter":
location.db.winter_desc = self.args
# clear flag to force an update
self.reset_times(location)
caller.msg("Seasonal description was set on %s." % location.key)
else:
# No seasonal desc set, maybe this is not an extended room
if self.rhs:
text = self.rhs
obj = caller.search(self.lhs)
if not obj:
return
else:
text = self.args
obj = location
obj.db.desc = text # a compatibility fallback
if obj.attributes.has("general_desc"):
obj.db.general_desc = text
self.reset_times(obj)
caller.msg("General description was set on %s." % obj.key)
else:
# this is not an ExtendedRoom.
caller.msg("The description was set on %s." % obj.key)
class CmdExtendedRoomDetail(default_cmds.MuxCommand):
"""
sets a detail on a room
Usage:
@detail[/del] <key> [= <description>]
@detail <key>;<alias>;... = description
Example:
@detail
@detail walls = The walls are covered in ...
@detail castle;ruin;tower = The distant ruin ...
@detail/del wall
@detail/del castle;ruin;tower
This command allows to show the current room details if you enter it
without any argument. Otherwise, sets or deletes a detail on the current
room, if this room supports details like an extended room. To add new
detail, just use the @detail command, specifying the key, an equal sign
and the description. You can assign the same description to several
details using the alias syntax (replace key by alias1;alias2;alias3;...).
To remove one or several details, use the @detail/del switch.
"""
key = "@detail"
locks = "cmd:perm(Builder)"
help_category = "Building"
def func(self):
location = self.caller.location
if not self.args:
details = location.db.details
if not details:
self.msg("|rThe room {} doesn't have any detail set.|n".format(location))
else:
details = sorted(["|y{}|n: {}".format(key, desc) for key, desc in details.items()])
self.msg("Details on Room:\n" + "\n".join(details))
return
if not self.rhs and "del" not in self.switches:
detail = location.return_detail(self.lhs)
if detail:
self.msg("Detail '|y{}|n' on Room:\n{}".format(self.lhs, detail))
else:
self.msg("Detail '{}' not found.".format(self.lhs))
return
method = "set_detail" if "del" not in self.switches else "del_detail"
if not hasattr(location, method):
self.caller.msg("Details cannot be set on %s." % location)
return
for key in self.lhs.split(";"):
# loop over all aliases, if any (if not, this will just be
# the one key to loop over)
getattr(location, method)(key, self.rhs)
if "del" in self.switches:
self.caller.msg("Detail %s deleted, if it existed." % self.lhs)
else:
self.caller.msg("Detail set '%s': '%s'" % (self.lhs, self.rhs))
# Simple command to view the current time and season
class CmdExtendedRoomGameTime(default_cmds.MuxCommand):
"""
Check the game time
Usage:
time
Shows the current in-game time and season.
"""
key = "time"
locks = "cmd:all()"
help_category = "General"
def func(self):
"""Reads time info from current room"""
location = self.caller.location
if not location or not hasattr(location, "get_time_and_season"):
self.caller.msg("No location available - you are outside time.")
else:
season, timeslot = location.get_time_and_season()
prep = "a"
if season == "autumn":
prep = "an"
self.caller.msg("It's %s %s day, in the %s." % (prep, season, timeslot))
# CmdSet for easily install all commands
class ExtendedRoomCmdSet(CmdSet):
"""
Groups the extended-room commands.
"""
def at_cmdset_creation(self):
self.add(CmdExtendedRoomLook)
self.add(CmdExtendedRoomDesc)
self.add(CmdExtendedRoomDetail)
self.add(CmdExtendedRoomGameTime)

View file

@ -0,0 +1,502 @@
# -*- coding: utf-8 -*-
"""
Evennia World Builder
Contribution - Cloud_Keeper 2016
Build a map from a 2D ASCII map.
This is a command which takes two inputs:
n MAP_LEGEND = {("", ""): build_forest,
("", "n"): build_mountains,
n (""): build_temple}
A string of ASCII characters representing a map and a dictionary of functions
containing build instructions. The characters of the map are iterated over and
compared to a list of trigger characters. When a match is found the
corresponding function is executed generating the rooms, exits and objects as
defined by the users build instructions. If a character is not a match to
a provided trigger character (including spaces) it is simply skipped and the
process continues.
For instance, the above map represents a temple () amongst mountains (n,)
in a forest (,) on an island surrounded by water (). Each character on the
first line is iterated over but as there is no match with our MAP_LEGEND it
is skipped. On the second line it finds "" which is a match and so the
`build_forest` function is called. Next the `build_mountains` function is
called and so on until the map is completed. Building instructions are passed
the following arguments:
x - The rooms position on the maps x axis
y - The rooms position on the maps y axis
caller - The account calling the command
iteration - The current iterations number (0, 1 or 2)
room_dict - A dictionary containing room references returned by build
functions where tuple coordinates are the keys (x, y).
ie room_dict[(2, 2)] will return the temple room above.
Building functions should return the room they create. By default these rooms
are used to create exits between valid adjacent rooms to the north, south,
east and west directions. This behaviour can turned off with the use of switch
arguments. In addition to turning off automatic exit generation the switches
allow the map to be iterated over a number of times. This is important for
something like custom exit building. Exits require a reference to both the
exits location and the exits destination. During the first iteration it is
possible that an exit is created pointing towards a destination that
has not yet been created resulting in error. By iterating over the map twice
the rooms can be created on the first iteration and room reliant code can be
be used on the second iteration. The iteration number and a dictionary of
references to rooms previously created is passed to the build commands.
Use by importing and including the command in your default_cmdsets module.
For example:
# mygame/commands/default_cmdsets.py
from evennia.contrib import mapbuilder
...
self.add(mapbuilder.CmdMapBuilder())
You then call the command in-game using the path to the MAP and MAP_LEGEND vars
The path you provide is relative to the evennia or mygame folder.
Usage:
@mapbuilder[/switch] <path.to.file.MAPNAME> <path.to.file.MAP_LEGEND>
Switches:
one - execute build instructions once without automatic exit creation.
two - execute build instructions twice without automatic exit creation.
Example:
@mapbuilder world.gamemap.MAP world.maplegend.MAP_LEGEND
@mapbuilder evennia.contrib.mapbuilder.EXAMPLE1_MAP EXAMPLE1_LEGEND
@mapbuilder/two evennia.contrib.mapbuilder.EXAMPLE2_MAP EXAMPLE2_LEGEND
(Legend path defaults to map path)
Below are two examples showcasing the use of automatic exit generation and
custom exit generation. Whilst located, and can be used, from this module for
convenience The below example code should be in mymap.py in mygame/world.
"""
from django.conf import settings
from evennia.utils import utils
# ---------- EXAMPLE 1 ---------- #
# @mapbuilder evennia.contrib.mapbuilder.EXAMPLE1_MAP EXAMPLE1_LEGEND
# -*- coding: utf-8 -*-
# Add the necessary imports for your instructions here.
from evennia import create_object
from typeclasses import rooms, exits
from random import randint
import random
# A map with a temple (▲) amongst mountains (n,∩) in a forest (♣,♠) on an
# island surrounded by water (≈). By giving no instructions for the water
# characters we effectively skip it and create no rooms for those squares.
EXAMPLE1_MAP = """\
n
n
"""
def example1_build_forest(x, y, **kwargs):
"""A basic example of build instructions. Make sure to include **kwargs
in the arguments and return an instance of the room for exit generation."""
# Create a room and provide a basic description.
room = create_object(rooms.Room, key="forest" + str(x) + str(y))
room.db.desc = "Basic forest room."
# Send a message to the account
kwargs["caller"].msg(room.key + " " + room.dbref)
# This is generally mandatory.
return room
def example1_build_mountains(x, y, **kwargs):
"""A room that is a little more advanced"""
# Create the room.
room = create_object(rooms.Room, key="mountains" + str(x) + str(y))
# Generate a description by randomly selecting an entry from a list.
room_desc = [
"Mountains as far as the eye can see",
"Your path is surrounded by sheer cliffs",
"Haven't you seen that rock before?",
]
room.db.desc = random.choice(room_desc)
# Create a random number of objects to populate the room.
for i in range(randint(0, 3)):
rock = create_object(key="Rock", location=room)
rock.db.desc = "An ordinary rock."
# Send a message to the account
kwargs["caller"].msg(room.key + " " + room.dbref)
# This is generally mandatory.
return room
def example1_build_temple(x, y, **kwargs):
"""A unique room that does not need to be as general"""
# Create the room.
room = create_object(rooms.Room, key="temple" + str(x) + str(y))
# Set the description.
room.db.desc = (
"In what, from the outside, appeared to be a grand and "
"ancient temple you've somehow found yourself in the the "
"Evennia Inn! It consists of one large room filled with "
"tables. The bardisk extends along the east wall, where "
"multiple barrels and bottles line the shelves. The "
"barkeep seems busy handing out ale and chatting with "
"the patrons, which are a rowdy and cheerful lot, "
"keeping the sound level only just below thunderous. "
"This is a rare spot of mirth on this dread moor."
)
# Send a message to the account
kwargs["caller"].msg(room.key + " " + room.dbref)
# This is generally mandatory.
return room
# Include your trigger characters and build functions in a legend dict.
EXAMPLE1_LEGEND = {
("", ""): example1_build_forest,
("", "n"): example1_build_mountains,
(""): example1_build_temple,
}
# ---------- EXAMPLE 2 ---------- #
# @mapbuilder/two evennia.contrib.mapbuilder.EXAMPLE2_MAP EXAMPLE2_LEGEND
# -*- coding: utf-8 -*-
# Add the necessary imports for your instructions here.
# from evennia import create_object
# from typeclasses import rooms, exits
# from evennia.utils import utils
# from random import randint
# import random
# This is the same layout as Example 1 but included are characters for exits.
# We can use these characters to determine which rooms should be connected.
EXAMPLE2_MAP = """\
--
| |
| | |
--
"""
def example2_build_forest(x, y, **kwargs):
"""A basic room"""
# If on anything other than the first iteration - Do nothing.
if kwargs["iteration"] > 0:
return None
room = create_object(rooms.Room, key="forest" + str(x) + str(y))
room.db.desc = "Basic forest room."
kwargs["caller"].msg(room.key + " " + room.dbref)
return room
def example2_build_verticle_exit(x, y, **kwargs):
"""Creates two exits to and from the two rooms north and south."""
# If on the first iteration - Do nothing.
if kwargs["iteration"] == 0:
return
north_room = kwargs["room_dict"][(x, y - 1)]
south_room = kwargs["room_dict"][(x, y + 1)]
# create exits in the rooms
create_object(
exits.Exit, key="south", aliases=["s"], location=north_room, destination=south_room
)
create_object(
exits.Exit, key="north", aliases=["n"], location=south_room, destination=north_room
)
kwargs["caller"].msg("Connected: " + north_room.key + " & " + south_room.key)
def example2_build_horizontal_exit(x, y, **kwargs):
"""Creates two exits to and from the two rooms east and west."""
# If on the first iteration - Do nothing.
if kwargs["iteration"] == 0:
return
west_room = kwargs["room_dict"][(x - 1, y)]
east_room = kwargs["room_dict"][(x + 1, y)]
create_object(exits.Exit, key="east", aliases=["e"], location=west_room, destination=east_room)
create_object(exits.Exit, key="west", aliases=["w"], location=east_room, destination=west_room)
kwargs["caller"].msg("Connected: " + west_room.key + " & " + east_room.key)
# Include your trigger characters and build functions in a legend dict.
EXAMPLE2_LEGEND = {
("", ""): example2_build_forest,
("|"): example2_build_verticle_exit,
("-"): example2_build_horizontal_exit,
}
# ---------- END OF EXAMPLES ---------- #
COMMAND_DEFAULT_CLASS = utils.class_from_module(settings.COMMAND_DEFAULT_CLASS)
# Helper function for readability.
def _map_to_list(game_map):
"""
Splits multi line map string into list of rows.
Args:
game_map (str): An ASCII map
Returns:
list (list): The map split into rows
"""
return game_map.split("\n")
def build_map(caller, game_map, legend, iterations=1, build_exits=True):
"""
Receives the fetched map and legend vars provided by the player.
Args:
caller (Object): The creator of the map.
game_map (str): An ASCII map string.
legend (dict): Mapping of map symbols to object types.
iterations (int): The number of iteration passes.
build_exits (bool): Create exits between new rooms.
Notes:
The map
is iterated over character by character, comparing it to the trigger
characters in the legend var and executing the build instructions on
finding a match. The map is iterated over according to the `iterations`
value and exits are optionally generated between adjacent rooms according
to the `build_exits` value.
"""
# Split map string to list of rows and create reference list.
caller.msg("Creating Map...")
caller.msg(game_map)
game_map = _map_to_list(game_map)
# Create a reference dictionary which be passed to build functions and
# will store obj returned by build functions so objs can be referenced.
room_dict = {}
caller.msg("Creating Landmass...")
for iteration in range(iterations):
for y in range(len(game_map)):
for x in range(len(game_map[y])):
for key in legend:
# obs - we must use == for strings
if game_map[y][x] == key:
room = legend[key](
x, y, iteration=iteration, room_dict=room_dict, caller=caller
)
if iteration == 0:
room_dict[(x, y)] = room
if build_exits:
# Creating exits. Assumes single room object in dict entry
caller.msg("Connecting Areas...")
for loc_key, location in room_dict.items():
x = loc_key[0]
y = loc_key[1]
# north
if (x, y - 1) in room_dict:
if room_dict[(x, y - 1)]:
create_object(
exits.Exit,
key="north",
aliases=["n"],
location=location,
destination=room_dict[(x, y - 1)],
)
# east
if (x + 1, y) in room_dict:
if room_dict[(x + 1, y)]:
create_object(
exits.Exit,
key="east",
aliases=["e"],
location=location,
destination=room_dict[(x + 1, y)],
)
# south
if (x, y + 1) in room_dict:
if room_dict[(x, y + 1)]:
create_object(
exits.Exit,
key="south",
aliases=["s"],
location=location,
destination=room_dict[(x, y + 1)],
)
# west
if (x - 1, y) in room_dict:
if room_dict[(x - 1, y)]:
create_object(
exits.Exit,
key="west",
aliases=["w"],
location=location,
destination=room_dict[(x - 1, y)],
)
caller.msg("Map Created.")
# access command
class CmdMapBuilder(COMMAND_DEFAULT_CLASS):
"""
Build a map from a 2D ASCII map.
Usage:
@mapbuilder[/switch] <path.to.file.MAPNAME> <path.to.file.MAP_LEGEND>
Switches:
one - execute build instructions once without automatic exit creation
two - execute build instructions twice without automatic exit creation
Example:
@mapbuilder world.gamemap.MAP world.maplegend.MAP_LEGEND
@mapbuilder evennia.contrib.mapbuilder.EXAMPLE1_MAP EXAMPLE1_LEGEND
@mapbuilder/two evennia.contrib.mapbuilder.EXAMPLE2_MAP EXAMPLE2_LEGEND
(Legend path defaults to map path)
This is a command which takes two inputs:
A string of ASCII characters representing a map and a dictionary of
functions containing build instructions. The characters of the map are
iterated over and compared to a list of trigger characters. When a match
is found the corresponding function is executed generating the rooms,
exits and objects as defined by the users build instructions. If a
character is not a match to a provided trigger character (including spaces)
it is simply skipped and the process continues. By default exits are
automatically generated but is turned off by switches which also determines
how many times the map is iterated over.
"""
key = "@mapbuilder"
aliases = ["@buildmap"]
locks = "cmd:superuser()"
help_category = "Building"
def func(self):
"""Starts the processor."""
caller = self.caller
args = self.args.split()
# Check if arguments passed.
if not self.args or (len(args) != 2):
caller.msg("Usage: @mapbuilder <path.to.module.VARNAME> " "<path.to.module.MAP_LEGEND>")
return
# Set up base variables.
game_map = None
legend = None
# OBTAIN MAP FROM MODULE
# Breaks down path_to_map into [PATH, VARIABLE]
path_to_map = args[0]
path_to_map = path_to_map.rsplit(".", 1)
try:
# Retrieves map variable from module or raises error.
game_map = utils.variable_from_module(path_to_map[0], path_to_map[1])
if not game_map:
raise ValueError(
"Command Aborted!\n"
"Path to map variable failed.\n"
"Usage: @mapbuilder <path.to.module."
"VARNAME> <path.to.module.MAP_LEGEND>"
)
except Exception as exc:
# Or relays error message if fails.
caller.msg(exc)
return
# OBTAIN MAP_LEGEND FROM MODULE
# Breaks down path_to_legend into [PATH, VARIABLE]
path_to_legend = args[1]
path_to_legend = path_to_legend.rsplit(".", 1)
# If no path given default to path_to_map's path
if len(path_to_legend) == 1:
path_to_legend.insert(0, path_to_map[0])
try:
# Retrieves legend variable from module or raises error if fails.
legend = utils.variable_from_module(path_to_legend[0], path_to_legend[1])
if not legend:
raise ValueError(
"Command Aborted!\n"
"Path to legend variable failed.\n"
"Usage: @mapbuilder <path.to.module."
"VARNAME> <path.to.module.MAP_LEGEND>"
)
except Exception as exc:
# Or relays error message if fails.
caller.msg(exc)
return
# Set up build_map arguments from switches
iterations = 1
build_exits = True
if "one" in self.switches:
build_exits = False
if "two" in self.switches:
iterations = 2
build_exits = False
# Pass map and legend to the build function.
build_map(caller, game_map, legend, iterations, build_exits)

View file

@ -0,0 +1,172 @@
"""
SimpleDoor
Contribution - Griatch 2016
A simple two-way exit that represents a door that can be opened and
closed. Can easily be expanded from to make it lockable, destroyable
etc. Note that the simpledoor is based on Evennia locks, so it will
not work for a superuser (which bypasses all locks) - the superuser
will always appear to be able to close/open the door over and over
without the locks stopping you. To use the door, use `@quell` or a
non-superuser account.
Installation:
Import this module in mygame/commands/default_cmdsets and add
the CmdOpen and CmdOpenCloseDoor commands to the CharacterCmdSet;
then reload the server.
To try it out, `@dig` a new room and then use the (overloaded) `@open`
commmand to open a new doorway to it like this:
@open doorway:contrib.simpledoor.SimpleDoor = otherroom
You can then use `open doorway' and `close doorway` to change the open
state. If you are not superuser (`@quell` yourself) you'll find you
cannot pass through either side of the door once it's closed from the
other side.
"""
from evennia import DefaultExit, default_cmds
from evennia.utils.utils import inherits_from
class SimpleDoor(DefaultExit):
"""
A two-way exit "door" with some methods for affecting both "sides"
of the door at the same time. For example, set a lock on either of the two
sides using `exitname.setlock("traverse:false())`
"""
def at_object_creation(self):
"""
Called the very first time the door is created.
"""
self.db.return_exit = None
def setlock(self, lockstring):
"""
Sets identical locks on both sides of the door.
Args:
lockstring (str): A lockstring, like `"traverse:true()"`.
"""
self.locks.add(lockstring)
self.db.return_exit.locks.add(lockstring)
def setdesc(self, description):
"""
Sets identical descs on both sides of the door.
Args:
setdesc (str): A description.
"""
self.db.desc = description
self.db.return_exit.db.desc = description
def delete(self):
"""
Deletes both sides of the door.
"""
# we have to be careful to avoid a delete-loop.
if self.db.return_exit:
super().delete()
super().delete()
return True
def at_failed_traverse(self, traversing_object):
"""
Called when door traverse: lock fails.
Args:
traversing_object (Typeclassed entity): The object
attempting the traversal.
"""
traversing_object.msg("%s is closed." % self.key)
class CmdOpen(default_cmds.CmdOpen):
__doc__ = default_cmds.CmdOpen.__doc__
# overloading parts of the default CmdOpen command to support doors.
def create_exit(self, exit_name, location, destination, exit_aliases=None, typeclass=None):
"""
Simple wrapper for the default CmdOpen.create_exit
"""
# create a new exit as normal
new_exit = super().create_exit(
exit_name, location, destination, exit_aliases=exit_aliases, typeclass=typeclass
)
if hasattr(self, "return_exit_already_created"):
# we don't create a return exit if it was already created (because
# we created a door)
del self.return_exit_already_created
return new_exit
if inherits_from(new_exit, SimpleDoor):
# a door - create its counterpart and make sure to turn off the default
# return-exit creation of CmdOpen
self.caller.msg(
"Note: A door-type exit was created - ignored eventual custom return-exit type."
)
self.return_exit_already_created = True
back_exit = self.create_exit(
exit_name, destination, location, exit_aliases=exit_aliases, typeclass=typeclass
)
new_exit.db.return_exit = back_exit
back_exit.db.return_exit = new_exit
return new_exit
# A simple example of a command making use of the door exit class'
# functionality. One could easily expand it with functionality to
# operate on other types of open-able objects as needed.
class CmdOpenCloseDoor(default_cmds.MuxCommand):
"""
Open and close a door
Usage:
open <door>
close <door>
"""
key = "open"
aliases = ["close"]
locks = "cmd:all()"
help_category = "General"
def func(self):
"implement the door functionality"
if not self.args:
self.caller.msg("Usage: open||close <door>")
return
door = self.caller.search(self.args)
if not door:
return
if not inherits_from(door, SimpleDoor):
self.caller.msg("This is not a door.")
return
if self.cmdstring == "open":
if door.locks.check(self.caller, "traverse"):
self.caller.msg("%s is already open." % door.key)
else:
door.setlock("traverse:true()")
self.caller.msg("You open %s." % door.key)
else: # close
if not door.locks.check(self.caller, "traverse"):
self.caller.msg("%s is already closed." % door.key)
else:
door.setlock("traverse:false()")
self.caller.msg("You close %s." % door.key)

View file

@ -0,0 +1,144 @@
"""
Slow Exit typeclass
Contribution - Griatch 2014
This is an example of an Exit-type that delays its traversal.This
simulates slow movement, common in many different types of games. The
contrib also contains two commands, CmdSetSpeed and CmdStop for changing
the movement speed and abort an ongoing traversal, respectively.
To try out an exit of this type, you could connect two existing rooms
using something like this:
@open north:contrib.slow_exit.SlowExit = <destination>
Installation:
To make this your new default exit, modify mygame/typeclasses/exits.py
to import this module and change the default Exit class to inherit
from SlowExit instead.
To get the ability to change your speed and abort your movement,
simply import and add CmdSetSpeed and CmdStop from this module to your
default cmdset (see tutorials on how to do this if you are unsure).
Notes:
This implementation is efficient but not persistent; so incomplete
movement will be lost in a server reload. This is acceptable for most
game types - to simulate longer travel times (more than the couple of
seconds assumed here), a more persistent variant using Scripts or the
TickerHandler might be better.
"""
from evennia import DefaultExit, utils, Command
MOVE_DELAY = {"stroll": 6, "walk": 4, "run": 2, "sprint": 1}
class SlowExit(DefaultExit):
"""
This overloads the way moving happens.
"""
def at_traverse(self, traversing_object, target_location):
"""
Implements the actual traversal, using utils.delay to delay the move_to.
"""
# if the traverser has an Attribute move_speed, use that,
# otherwise default to "walk" speed
move_speed = traversing_object.db.move_speed or "walk"
move_delay = MOVE_DELAY.get(move_speed, 4)
def move_callback():
"This callback will be called by utils.delay after move_delay seconds."
source_location = traversing_object.location
if traversing_object.move_to(target_location):
self.at_post_traverse(traversing_object, source_location)
else:
if self.db.err_traverse:
# if exit has a better error message, let's use it.
self.caller.msg(self.db.err_traverse)
else:
# No shorthand error message. Call hook.
self.at_failed_traverse(traversing_object)
traversing_object.msg("You start moving %s at a %s." % (self.key, move_speed))
# create a delayed movement
t = utils.delay(move_delay, move_callback)
# we store the deferred on the character, this will allow us
# to abort the movement. We must use an ndb here since
# deferreds cannot be pickled.
traversing_object.ndb.currently_moving = t
#
# set speed - command
#
SPEED_DESCS = {"stroll": "strolling", "walk": "walking", "run": "running", "sprint": "sprinting"}
class CmdSetSpeed(Command):
"""
set your movement speed
Usage:
setspeed stroll|walk|run|sprint
This will set your movement speed, determining how long time
it takes to traverse exits. If no speed is set, 'walk' speed
is assumed.
"""
key = "setspeed"
def func(self):
"""
Simply sets an Attribute used by the SlowExit above.
"""
speed = self.args.lower().strip()
if speed not in SPEED_DESCS:
self.caller.msg("Usage: setspeed stroll||walk||run||sprint")
elif self.caller.db.move_speed == speed:
self.caller.msg("You are already %s." % SPEED_DESCS[speed])
else:
self.caller.db.move_speed = speed
self.caller.msg("You are now %s." % SPEED_DESCS[speed])
#
# stop moving - command
#
class CmdStop(Command):
"""
stop moving
Usage:
stop
Stops the current movement, if any.
"""
key = "stop"
def func(self):
"""
This is a very simple command, using the
stored deferred from the exit traversal above.
"""
currently_moving = self.caller.ndb.currently_moving
if currently_moving and not currently_moving.called:
currently_moving.cancel()
self.caller.msg("You stop moving.")
for observer in self.caller.location.contents_get(self.caller):
observer.msg("%s stops." % self.caller.get_display_name(observer))
else:
self.caller.msg("You are not moving.")

View file

@ -0,0 +1,777 @@
"""
Wilderness system
Evennia contrib - titeuf87 2017
This contrib provides a wilderness map. This is an area that can be huge where
the rooms are mostly similar, except for some small cosmetic changes like the
room name.
Usage:
This contrib does not provide any commands. Instead the @py command can be
used.
A wilderness map needs to created first. There can be different maps, all
with their own name. If no name is provided, then a default one is used. Internally,
the wilderness is stored as a Script with the name you specify. If you don't
specify the name, a script named "default" will be created and used.
@py from evennia.contrib import wilderness; wilderness.create_wilderness()
Once created, it is possible to move into that wilderness map:
@py from evennia.contrib import wilderness; wilderness.enter_wilderness(me)
All coordinates used by the wilderness map are in the format of `(x, y)`
tuples. x goes from left to right and y goes from bottom to top. So `(0, 0)`
is the bottom left corner of the map.
Customisation:
The defaults, while useable, are meant to be customised. When creating a
new wilderness map it is possible to give a "map provider": this is a
python object that is smart enough to create the map.
The default provider, WildernessMapProvider, just creates a grid area that
is unlimited in size.
This WildernessMapProvider can be subclassed to create more interesting
maps and also to customize the room/exit typeclass used.
There is also no command that allows players to enter the wilderness. This
still needs to be added: it can be a command or an exit, depending on your
needs.
Customisation example:
To give an example of how to customize, we will create a very simple (and
small) wilderness map that is shaped like a pyramid. The map will be
provided as a string: a "." symbol is a location we can walk on.
Let's create a file world/pyramid.py:
```python
map_str = \"\"\"
.
...
.....
.......
\"\"\"
from evennia.contrib import wilderness
class PyramidMapProvider(wilderness.WildernessMapProvider):
def is_valid_coordinates(self, wilderness, coordinates):
"Validates if these coordinates are inside the map"
x, y = coordinates
try:
lines = map_str.split("\n")
# The reverse is needed because otherwise the pyramid will be
# upside down
lines.reverse()
line = lines[y]
column = line[x]
return column == "."
except IndexError:
return False
def get_location_name(self, coordinates):
"Set the location name"
x, y = coordinates
if y == 3:
return "Atop the pyramid."
else:
return "Inside a pyramid."
def at_prepare_room(self, coordinates, caller, room):
"Any other changes done to the room before showing it"
x, y = coordinates
desc = "This is a room in the pyramid."
if y == 3 :
desc = "You can see far and wide from the top of the pyramid."
room.db.desc = desc
```
Now we can use our new pyramid-shaped wilderness map. From inside Evennia we
create a new wilderness (with the name "default") but using our new map provider:
```
@py from world import pyramid as p; p.wilderness.create_wilderness(mapprovider=p.PyramidMapProvider())
@py from evennia.contrib import wilderness; wilderness.enter_wilderness(me, coordinates=(4, 1))
```
Implementation details:
When a character moves into the wilderness, they get their own room. If
they move, instead of moving the character, the room changes to match the
new coordinates.
If a character meets another character in the wilderness, then their room
merges. When one of the character leaves again, they each get their own
separate rooms.
Rooms are created as needed. Unneeded rooms are stored away to avoid the
overhead cost of creating new rooms again in the future.
"""
from evennia import DefaultRoom, DefaultExit, DefaultScript
from evennia import create_object, create_script
from evennia.utils import inherits_from
def create_wilderness(name="default", mapprovider=None):
"""
Creates a new wilderness map. Does nothing if a wilderness map already
exists with the same name.
Args:
name (str, optional): the name to use for that wilderness map
mapprovider (WildernessMap instance, optional): an instance of a
WildernessMap class (or subclass) that will be used to provide the
layout of this wilderness map. If none is provided, the default
infinite grid map will be used.
"""
if WildernessScript.objects.filter(db_key=name).exists():
# Don't create two wildernesses with the same name
return
if not mapprovider:
mapprovider = WildernessMapProvider()
script = create_script(WildernessScript, key=name)
script.db.mapprovider = mapprovider
def enter_wilderness(obj, coordinates=(0, 0), name="default"):
"""
Moves obj into the wilderness. The wilderness needs to exist first and the
provided coordinates needs to be valid inside that wilderness.
Args:
obj (object): the object to move into the wilderness
coordinates (tuple), optional): the coordinates to move obj to into
the wilderness. If not provided, defaults (0, 0)
name (str, optional): name of the wilderness map, if not using the
default one
Returns:
bool: True if obj successfully moved into the wilderness.
"""
if not WildernessScript.objects.filter(db_key=name).exists():
return False
script = WildernessScript.objects.get(db_key=name)
if script.is_valid_coordinates(coordinates):
script.move_obj(obj, coordinates)
return True
else:
return False
def get_new_coordinates(coordinates, direction):
"""
Returns the coordinates of direction applied to the provided coordinates.
Args:
coordinates: tuple of (x, y)
direction: a direction string (like "northeast")
Returns:
tuple: tuple of (x, y) coordinates
"""
x, y = coordinates
if direction in ("north", "northwest", "northeast"):
y += 1
if direction in ("south", "southwest", "southeast"):
y -= 1
if direction in ("northwest", "west", "southwest"):
x -= 1
if direction in ("northeast", "east", "southeast"):
x += 1
return (x, y)
class WildernessScript(DefaultScript):
"""
This is the main "handler" for the wilderness system: inside here the
coordinates of every item currently inside the wilderness is stored. This
script is responsible for creating rooms as needed and storing rooms away
into storage when they are not needed anymore.
"""
def at_script_creation(self):
"""
Only called once, when the script is created. This is a default Evennia
hook.
"""
self.persistent = True
# Store the coordinates of every item that is inside the wilderness
# Key: object, Value: (x, y)
self.db.itemcoordinates = {}
# Store the rooms that are used as views into the wilderness
# Key: (x, y), Value: room object
self.db.rooms = {}
# Created rooms that are not needed anymore are stored there. This
# allows quick retrieval if a new room is needed without having to
# create it.
self.db.unused_rooms = []
@property
def mapprovider(self):
"""
Shortcut property to the map provider.
Returns:
MapProvider: the mapprovider used with this wilderness
"""
return self.db.mapprovider
@property
def itemcoordinates(self):
"""
Returns a dictionary with the coordinates of every item inside this
wilderness map. The key is the item, the value are the coordinates as
(x, y) tuple.
Returns:
{item: coordinates}
"""
return self.db.itemcoordinates
def at_start(self):
"""
Called when the script is started and also after server reloads.
"""
for coordinates, room in self.db.rooms.items():
room.ndb.wildernessscript = self
room.ndb.active_coordinates = coordinates
for item in list(self.db.itemcoordinates.keys()):
# Items deleted from the wilderness leave None type 'ghosts'
# that must be cleaned out
if item is None:
del self.db.itemcoordinates[item]
continue
item.ndb.wilderness = self
def is_valid_coordinates(self, coordinates):
"""
Returns True if coordinates are valid (and can be travelled to).
Otherwise returns False
Args:
coordinates (tuple): coordinates as (x, y) tuple
Returns:
bool: True if the coordinates are valid
"""
return self.mapprovider.is_valid_coordinates(self, coordinates)
def get_obj_coordinates(self, obj):
"""
Returns the coordinates of obj in the wilderness.
Returns (x, y)
Args:
obj (object): an object inside the wilderness
Returns:
tuple: (x, y) tuple of where obj is located
"""
return self.itemcoordinates[obj]
def get_objs_at_coordinates(self, coordinates):
"""
Returns a list of every object at certain coordinates.
Imeplementation detail: this uses a naive iteration through every
object inside the wilderness which could cause slow downs when there
are a lot of objects in the map.
Args:
coordinates (tuple): a coordinate tuple like (x, y)
Returns:
[Object, ]: list of Objects at coordinates
"""
result = []
for item, item_coordinates in list(self.itemcoordinates.items()):
# Items deleted from the wilderness leave None type 'ghosts'
# that must be cleaned out
if item is None:
del self.db.itemcoordinates[item]
continue
if coordinates == item_coordinates:
result.append(item)
return result
def move_obj(self, obj, new_coordinates):
"""
Moves obj to new coordinates in this wilderness.
Args:
obj (object): the object to move
new_coordinates (tuple): tuple of (x, y) where to move obj to.
"""
# Update the position of this obj in the wilderness
self.itemcoordinates[obj] = new_coordinates
old_room = obj.location
# Remove the obj's location. This is needed so that the object does not
# appear in its old room should that room be deleted.
obj.location = None
try:
# See if we already have a room for that location
room = self.db.rooms[new_coordinates]
# There is. Try to destroy the old_room if it is not needed anymore
self._destroy_room(old_room)
except KeyError:
# There is no room yet at new_location
if (old_room and not inherits_from(old_room, WildernessRoom)) or (not old_room):
# Obj doesn't originally come from a wilderness room.
# We'll create a new one then.
room = self._create_room(new_coordinates, obj)
else:
# Obj does come from another wilderness room
create_new_room = False
if old_room.wilderness != self:
# ... but that other wilderness room belongs to another
# wilderness map
create_new_room = True
old_room.wilderness.at_post_object_leave(obj)
else:
for item in old_room.contents:
if item.has_account:
# There is still a player in the old room.
# Let's create a new room and not touch that old
# room.
create_new_room = True
break
if create_new_room:
# Create a new room to hold obj, not touching any obj's in
# the old room
room = self._create_room(new_coordinates, obj)
else:
# The old_room is empty: we are just going to reuse that
# room instead of creating a new one
room = old_room
room.set_active_coordinates(new_coordinates, obj)
obj.location = room
obj.ndb.wilderness = self
def _create_room(self, coordinates, report_to):
"""
Gets a new WildernessRoom to be used for the provided coordinates.
It first tries to retrieve a room out of storage. If there are no rooms
left a new one will be created.
Args:
coordinates (tuple): coordinate tuple of (x, y)
report_to (object): the obj to return error messages to
"""
if self.db.unused_rooms:
# There is still unused rooms stored in storage, let's get one of
# those
room = self.db.unused_rooms.pop()
else:
# No more unused rooms...time to make a new one.
# First, create the room
room = create_object(
typeclass=self.mapprovider.room_typeclass, key="Wilderness", report_to=report_to
)
# Then the exits
exits = [
("north", "n"),
("northeast", "ne"),
("east", "e"),
("southeast", "se"),
("south", "s"),
("southwest", "sw"),
("west", "w"),
("northwest", "nw"),
]
for key, alias in exits:
create_object(
typeclass=self.mapprovider.exit_typeclass,
key=key,
aliases=[alias],
location=room,
destination=room,
report_to=report_to,
)
room.ndb.active_coordinates = coordinates
room.ndb.wildernessscript = self
self.db.rooms[coordinates] = room
return room
def _destroy_room(self, room):
"""
Moves a room back to storage. If room is not a WildernessRoom or there
is a player inside the room, then this does nothing.
Args:
room (WildernessRoom): the room to put in storage
"""
if not room or not inherits_from(room, WildernessRoom):
return
for item in room.contents:
if item.has_account:
# There is still a character in that room. We can't get rid of
# it just yet
break
else:
# No characters left in the room.
# Clear the location of every obj in that room first
for item in room.contents:
if item.destination and item.destination == room:
# Ignore the exits, they stay in the room
continue
item.location = None
# Then delete its reference
del self.db.rooms[room.ndb.active_coordinates]
# And finally put this room away in storage
self.db.unused_rooms.append(room)
def at_post_object_leave(self, obj):
"""
Called after an object left this wilderness map. Used for cleaning up.
Args:
obj (object): the object that left
"""
# Remove that obj from the wilderness's coordinates dict
loc = self.db.itemcoordinates[obj]
del self.db.itemcoordinates[obj]
# And see if we can put that room away into storage.
room = self.db.rooms[loc]
self._destroy_room(room)
class WildernessRoom(DefaultRoom):
"""
This is a single room inside the wilderness. This room provides a "view"
into the wilderness map. When an account moves around, instead of going to
another room as with traditional rooms, they stay in the same room but the
room itself changes to display another area of the wilderness.
"""
@property
def wilderness(self):
"""
Shortcut property to the wilderness script this room belongs to.
Returns:
WildernessScript: the WildernessScript attached to this room
"""
return self.ndb.wildernessscript
@property
def location_name(self):
"""
Returns the name of the wilderness at this room's coordinates.
Returns:
name (str)
"""
return self.wilderness.mapprovider.get_location_name(self.coordinates)
@property
def coordinates(self):
"""
Returns the coordinates of this room into the wilderness.
Returns:
tuple: (x, y) coordinates of where this room is inside the
wilderness.
"""
return self.ndb.active_coordinates
def at_object_receive(self, moved_obj, source_location):
"""
Called after an object has been moved into this object. This is a
default Evennia hook.
Args:
moved_obj (Object): The object moved into this one.
source_location (Object): Where `moved_obj` came from.
"""
if isinstance(moved_obj, WildernessExit):
# Ignore exits looping back to themselves: those are the regular
# n, ne, ... exits.
return
itemcoords = self.wilderness.db.itemcoordinates
if moved_obj in itemcoords:
# This object was already in the wilderness. We need to make sure
# it goes to the correct room it belongs to.
# Otherwise the following issue can come up:
# 1) Player 1 and Player 2 share a room
# 2) Player 1 disconnects
# 3) Player 2 moves around
# 4) Player 1 reconnects
# Player 1 will end up in player 2's room, which has the wrong
# coordinates
coordinates = itemcoords[moved_obj]
# Setting the location to None is important here so that we always
# get a "fresh" room
moved_obj.location = None
self.wilderness.move_obj(moved_obj, coordinates)
else:
# This object wasn't in the wilderness yet. Let's add it.
itemcoords[moved_obj] = self.coordinates
def at_object_leave(self, moved_obj, target_location):
"""
Called just before an object leaves from inside this object. This is a
default Evennia hook.
Args:
moved_obj (Object): The object leaving
target_location (Object): Where `moved_obj` is going.
"""
self.wilderness.at_post_object_leave(moved_obj)
def set_active_coordinates(self, new_coordinates, obj):
"""
Changes this room to show the wilderness map from other coordinates.
Args:
new_coordinates (tuple): coordinates as tuple of (x, y)
obj (Object): the object that moved into this room and caused the
coordinates to change
"""
# Remove the reference for the old coordinates...
rooms = self.wilderness.db.rooms
del rooms[self.coordinates]
# ...and add it for the new coordinates.
self.ndb.active_coordinates = new_coordinates
rooms[self.coordinates] = self
# Every obj inside this room will get its location set to None
for item in self.contents:
if not item.destination or item.destination != item.location:
item.location = None
# And every obj matching the new coordinates will get its location set
# to this room
for item in self.wilderness.get_objs_at_coordinates(new_coordinates):
item.location = self
# Fix the lockfuncs for the exit so we can't go where we're not
# supposed to go
for exit in self.exits:
if exit.destination != self:
continue
x, y = get_new_coordinates(new_coordinates, exit.key)
valid = self.wilderness.is_valid_coordinates((x, y))
if valid:
exit.locks.add("traverse:true();view:true()")
else:
exit.locks.add("traverse:false();view:false()")
# Finally call the at_prepare_room hook to give a chance to further
# customise it
self.wilderness.mapprovider.at_prepare_room(new_coordinates, obj, self)
def get_display_name(self, looker, **kwargs):
"""
Displays the name of the object in a viewer-aware manner.
Args:
looker (TypedObject): The object or account that is looking
at/getting inforamtion for this object.
Returns:
name (str): A string containing the name of the object,
including the DBREF if this user is privileged to control
said object and also its coordinates into the wilderness map.
Notes:
This function could be extended to change how object names
appear to users in character, but be wary. This function
does not change an object's keys or aliases when
searching, and is expected to produce something useful for
builders.
"""
if self.locks.check_lockstring(looker, "perm(Builder)"):
name = "{}(#{})".format(self.location_name, self.id)
else:
name = self.location_name
name += " {0}".format(self.coordinates)
return name
class WildernessExit(DefaultExit):
"""
This is an Exit object used inside a WildernessRoom. Instead of changing
the location of an Object traversing through it (like a traditional exit
would do) it changes the coordinates of that traversing Object inside
the wilderness map.
"""
@property
def wilderness(self):
"""
Shortcut property to the wilderness script.
Returns:
WildernessScript: the WildernessScript attached to this exit's room
"""
return self.location.wilderness
@property
def mapprovider(self):
"""
Shortcut property to the map provider.
Returns:
MapProvider object: the mapprovider object used with this
wilderness map.
"""
return self.wilderness.mapprovider
def at_traverse_coordinates(self, traversing_object, current_coordinates, new_coordinates):
"""
Called when an object wants to travel from one place inside the
wilderness to another place inside the wilderness.
If this returns True, then the traversing can happen. Otherwise it will
be blocked.
This method is similar how the `at_traverse` works on normal exits.
Args:
traversing_object (Object): The object doing the travelling.
current_coordinates (tuple): (x, y) coordinates where
`traversing_object` currently is.
new_coordinates (tuple): (x, y) coordinates of where
`traversing_object` wants to travel to.
Returns:
bool: True if traversing_object is allowed to traverse
"""
return True
def at_traverse(self, traversing_object, target_location):
"""
This implements the actual traversal. The traverse lock has
already been checked (in the Exit command) at this point.
Args:
traversing_object (Object): Object traversing us.
target_location (Object): Where target is going.
Returns:
bool: True if the traverse is allowed to happen
"""
itemcoordinates = self.location.wilderness.db.itemcoordinates
current_coordinates = itemcoordinates[traversing_object]
new_coordinates = get_new_coordinates(current_coordinates, self.key)
if not self.at_traverse_coordinates(
traversing_object, current_coordinates, new_coordinates
):
return False
if not traversing_object.at_pre_move(None):
return False
traversing_object.location.msg_contents(
"{} leaves to {}".format(traversing_object.key, new_coordinates),
exclude=[traversing_object],
)
self.location.wilderness.move_obj(traversing_object, new_coordinates)
traversing_object.location.msg_contents(
"{} arrives from {}".format(traversing_object.key, current_coordinates),
exclude=[traversing_object],
)
traversing_object.at_post_move(None)
return True
class WildernessMapProvider(object):
"""
Default Wilderness Map provider.
This is a simple provider that just creates an infinite large grid area.
"""
room_typeclass = WildernessRoom
exit_typeclass = WildernessExit
def is_valid_coordinates(self, wilderness, coordinates):
"""Returns True if coordinates is valid and can be walked to.
Args:
wilderness: the wilderness script
coordinates (tuple): the coordinates to check as (x, y) tuple.
Returns:
bool: True if the coordinates are valid
"""
x, y = coordinates
if x < 0:
return False
if y < 0:
return False
return True
def get_location_name(self, coordinates):
"""
Returns a name for the position at coordinates.
Args:
coordinates (tuple): the coordinates as (x, y) tuple.
Returns:
name (str)
"""
return "The wilderness"
def at_prepare_room(self, coordinates, caller, room):
"""
Called when a room gets activated for certain coordinates. This happens
after every object is moved in it.
This can be used to set a custom room desc for instance or run other
customisations on the room.
Args:
coordinates (tuple): the coordinates as (x, y) where room is
located at
caller (Object): the object that moved into this room
room (WildernessRoom): the room object that will be used at that
wilderness location
Example:
An example use of this would to plug in a randomizer to show different
descriptions for different coordinates, or place a treasure at a special
coordinate.
"""
pass

View file

@ -0,0 +1,66 @@
# XYZgrid
Full grid coordinate- pathfinding and visualization system
Evennia Contrib by Griatch 2021
The default Evennia's rooms are non-euclidian - they can connect
to each other with any types of exits without necessarily having a clear
position relative to each other. This gives maximum flexibility, but many games
want to use cardinal movements (north, east etc) and also features like finding
the shortest-path between two points.
This contrib forces each room to exist on a 3-dimensional XYZ grid and also
implements very efficient pathfinding along with tools for displaying
your current visual-range and a lot of related features.
The rooms of the grid are entirely controlled from outside the game, using
python modules with strings and dicts defining the map(s) of the game. It's
possible to combine grid- with non-grid rooms, and you can decorate
grid rooms as much as you like in-game, but you cannot spawn new grid
rooms without editing the map files outside of the game.
The full docs are found as
[Contribs/XYZGrid](https://evennia.com/docs/latest/Contributions/XYZGrid.html)
in the docs.
## Installation
1. If you haven't before, install the extra contrib requirements.
You can do so by doing `pip install -r requirements_extra.txt` from the
`evennia/` folder.
2. Import and add the `evennia.contrib.xyzgrid.commands.XYZGridCmdSet` to the
`CharacterCmdset` cmdset in `mygame/commands.default_cmds.py`. Reload
the server. This makes the `map`, `goto/path` and modified `teleport` and
`open` commands available in-game.
3. Edit `mygame/server/conf/settings.py` and set
EXTRA_LAUNCHER_COMMANDS['xyzgrid'] = 'evennia.contrib.xyzgrid.launchcmd.xyzcommand'
4. Run the new `evennia xyzgrid help` for instructions on how to spawn the grid.
## Example usage
After installation, do the following (from your command line, where the
`evennia` command is available) to install an example grid:
evennia xyzgrid init
evennia xyzgrid add evennia.contrib.xyzgrid.example
evennia xyzgrid list
evennia xyzgrid show "the large tree"
evennia xyzgrid show "the small cave"
evennia xyzgrid spawn
evennia reload
(remember to reload the server after spawn operations).
Now you can log into the
server and do `teleport (3,0,the large tree)` to teleport into the map.
You can use `open togrid = (3, 0, the large tree)` to open a permanent (one-way)
exit from your current location into the grid. To make a way back to a non-grid
location just stand in a grid room and open a new exit out of it:
`open tolimbo = #2`.
Try `goto view` to go to the top of the tree and `goto dungeon` to go down to
the dungeon entrance at the bottom of the tree.

View file

View file

@ -0,0 +1,474 @@
"""
XYZ-aware commands
Just add the XYZGridCmdSet to the default character cmdset to override
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 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
Usage:
tel/switch [<object> to||=] <target location>
tel/switch [<object> to||=] (X,Y[,Z])
Examples:
tel Limbo
tel/quiet box = Limbo
tel/tonone box
tel (3, 3, the small cave)
tel (4, 1) # on the same map
tel/map Z|mapname
Switches:
quiet - don't echo leave/arrive messages to the source/target
locations for the move.
intoexit - if target is an exit, teleport INTO
the exit object instead of to its destination
tonone - if set, teleport the object to a None-location. If this
switch is set, <target location> is ignored.
Note that the only way to retrieve
an object from a None location is by direct #dbref
reference. A puppeted object cannot be moved to None.
loc - teleport object to the target's location instead of its contents
map - show coordinate map of given Zcoord/mapname.
Teleports an object somewhere. If no object is given, you yourself are
teleported to the target location. If (X,Y) or (X,Y,Z) coordinates
are given, the target is a location on the XYZGrid.
"""
def _search_by_xyz(self, inp):
inp = inp.strip("()")
X, Y, *Z = inp.split(",", 2)
if Z:
# Z was specified
Z = Z[0]
else:
# use current location's Z, if it exists
try:
xyz = self.caller.xyz
except AttributeError:
self.caller.msg("Z-coordinate is also required since you are not currently "
"in a room with a Z coordinate of its own.")
raise InterruptCommand
else:
Z = xyz[2]
# search by coordinate
X, Y, Z = str(X).strip(), str(Y).strip(), str(Z).strip()
try:
self.destination = XYZRoom.objects.get_xyz(xyz=(X, Y, Z))
except XYZRoom.DoesNotExist:
self.caller.msg(f"Found no target XYZRoom at ({X},{Y},{Z}).")
raise InterruptCommand
def parse(self):
default_cmds.MuxCommand.parse(self)
self.obj_to_teleport = self.caller
self.destination = None
if self.rhs:
self.obj_to_teleport = self.caller.search(self.lhs, global_search=True)
if not self.obj_to_teleport:
self.caller.msg("Did not find object to teleport.")
raise InterruptCommand
if all(char in self.rhs for char in ("(", ")", ",")):
# search by (X,Y) or (X,Y,Z)
self._search_by_xyz(self.rhs)
else:
# fallback to regular search by name/alias
self.destination = self.caller.search(self.rhs, global_search=True)
elif self.lhs:
if all(char in self.lhs for char in ("(", ")", ",")):
self._search_by_xyz(self.lhs)
else:
self.destination = self.caller.search(self.lhs, global_search=True)
class CmdXYZOpen(building.CmdOpen):
"""
open a new exit from the current room
Usage:
open <new exit>[;alias;..][:typeclass] [,<return exit>[;alias;..][:typeclass]]] = <destination>
open <new exit>[;alias;..][:typeclass] [,<return exit>[;alias;..][:typeclass]]] = (X,Y,Z)
Handles the creation of exits. If a destination is given, the exit
will point there. The destination can also be given as an (X,Y,Z) coordinate on the
XYZGrid - this command is used to link non-grid rooms to the grid and vice-versa.
The <return exit> argument sets up an exit at the destination leading back to the current room.
Apart from (X,Y,Z) coordinate, destination name can be given both as a #dbref and a name, if
that name is globally unique.
Examples:
open kitchen = Kitchen
open north, south = Town Center
open cave mouth;cave = (3, 4, the small cave)
"""
def parse(self):
building.ObjManipCommand.parse(self)
self.location = self.caller.location
if not self.args or not self.rhs:
self.caller.msg("Usage: open <new exit>[;alias...][:typeclass]"
"[,<return exit>[;alias..][:typeclass]]] "
"= <destination or (X,Y,Z)>")
raise InterruptCommand
if not self.location:
self.caller.msg("You cannot create an exit from a None-location.")
raise InterruptCommand
if all(char in self.rhs for char in ("(", ")", ",")):
# search by (X,Y) or (X,Y,Z)
X, Y, *Z = self.rhs.split(",", 2)
if not Z:
self.caller.msg("A full (X,Y,Z) coordinate must be given for the destination.")
raise InterruptCommand
Z = Z[0]
# search by coordinate
X, Y, Z = str(X).strip(), str(Y).strip(), str(Z).strip()
try:
self.destination = XYZRoom.objects.get_xyz(xyz=(X, Y, Z))
except XYZRoom.DoesNotExist:
self.caller.msg("Found no target XYZRoom at ({X},{Y},{Y}).")
raise InterruptCommand
else:
# regular search query
self.destination = self.caller.search(self.rhs, global_search=True)
if not self.destination:
raise InterruptCommand
self.exit_name = self.lhs_objs[0]["name"]
self.exit_aliases = self.lhs_objs[0]["aliases"]
self.exit_typeclass = self.lhs_objs[0]["option"]
class CmdGoto(COMMAND_DEFAULT_CLASS):
"""
Go to a named location in this area via the shortest path.
Usage:
path <location> - find shortest path to target location (don't move)
goto <location> - 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
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"
aliases = "path"
help_category = "General"
locks = "cmd:all()"
# how quickly to step (seconds)
auto_step_delay = 2
default_xyz_path_interrupt_msg = "Pathfinding interrupted here."
def _search_by_xyz(self, inp, xyz_start):
inp = inp.strip("()")
X, Y = inp.split(",", 2)
Z = xyz_start[2]
# search by coordinate
X, Y, Z = str(X).strip(), str(Y).strip(), str(Z).strip()
try:
return XYZRoom.objects.get_xyz(xyz=(X, Y, Z))
except XYZRoom.DoesNotExist:
self.caller.msg(f"Could not find a room at ({X},{Y}) (Z={Z}).")
return None
def _search_by_key_and_alias(self, inp, xyz_start):
Z = xyz_start[2]
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
)
# the map can itself tell the stepper to stop the auto-step prematurely
interrupt_node_or_link = None
# pop any extra links up until the next node - these are
# not useful when dealing with exits
while step_sequence:
if not interrupt_node_or_link and step_sequence[0].interrupt_path:
interrupt_node_or_link = step_sequence[0]
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', )))
exit_obj = caller.search(exit_name)
if not exit_obj:
# 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
if interrupt_node_or_link:
# premature stop of pathfind-step because of map node/link of interrupt type
if hasattr(interrupt_node_or_link, "node_index"):
message = exit_obj.destination.attributes.get(
"xyz_path_interrupt_msg", default=self.default_xyz_path_interrupt_msg)
# we move into the node/room and then stop
caller.execute_cmd(exit_name, session=session)
else:
# if the link is interrupted we don't cross it at all
message = exit_obj.attributes.get(
"xyz_path_interrupt_msg", default=self.default_xyz_path_interrupt_msg)
caller.msg(message)
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
goto_mode = self.cmdname == 'goto'
# check if we have an existing path
path_data = caller.ndb.xy_path_data
if not self.args:
if path_data:
target_name = path_data.target.get_display_name(caller)
task = path_data.task
if goto_mode:
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|path [<location>]")
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
except AttributeError:
self.caller.msg("Cannot path-find since the current location is not on the grid.")
return
allow_xyz_query = caller.locks.check_lockstring(caller, "perm(Builder)")
if allow_xyz_query and all(char in self.args for char in ("(", ")", ",")):
# search by (X,Y)
target = self._search_by_xyz(self.args, xyz_start)
if not target:
return
else:
# search by normal key/alias
target = self._search_by_key_and_alias(self.args, xyz_start)
if not target:
return
try:
xyz_end = target.xyz
except AttributeError:
self.caller.msg("Target location is not on the grid and cannot be auto-walked to.")
return
xymap = xyzgrid.get_map(xyz_start[2])
# we only need the xy coords once we have the map
xy_start = xyz_start[:2]
xy_end = xyz_end[:2]
directions, step_sequence = xymap.get_shortest_path(xy_start, xy_end)
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")
# 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)
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):
"""
Cmdset for easily adding the above cmds to the character cmdset.
"""
key = "xyzgrid_cmdset"
def at_cmdset_creation(self):
self.add(CmdXYZTeleport())
self.add(CmdXYZOpen())
self.add(CmdGoto())
self.add(CmdMap())

View file

@ -0,0 +1,291 @@
"""
Example xymaps to use with the XYZgrid contrib. Build outside of the game using
the `evennia xyzgrid` launcher command.
First add the launcher extension in your mygame/server/conf/settings.py:
EXTRA_LAUNCHER_COMMANDS['xyzgrid'] = 'evennia.contrib.xyzgrid.launchcmd.xyzcommand'
Then
evennia xyzgrid init
evennia xyzgrid add evennia.contrib.xyzgrid.map_example
evennia xyzgrid build
"""
from evennia.contrib.xyzgrid import xymap_legend
# default prototype parent. It's important that
# the typeclass inherits from the XYZRoom (or XYZExit)
# if adding the evennia.contrib.xyzgrid.prototypes to
# settings.PROTOTYPE_MODULES, one could just set the
# prototype_parent to 'xyz_room' and 'xyz_exit' here
# instead.
ROOM_PARENT = {
"key": "An empty room",
"prototype_key": "xyz_exit_prototype",
# "prototype_parent": "xyz_room",
"typeclass": "evennia.contrib.xyzgrid.xyzroom.XYZRoom",
"desc": "An empty room.",
}
EXIT_PARENT = {
"prototype_key": "xyz_exit_prototype",
# "prototype_parent": "xyz_exit",
"typeclass": "evennia.contrib.xyzgrid.xyzroom.XYZExit",
"desc": "A path to the next location.",
}
# ---------------------------------------- map1
# The large tree
#
# this exemplifies the various map symbols
# but is not heavily prototyped
MAP1 = r"""
1
+ 0 1 2 3 4 5 6 7 8 9 0
8 #-------#-#-------I
\ /
7 #-#---# t-#
|\ |
6 #i#-#b--#-t
| |
5 o-#---#
\ /
4 o---#-#
/ d
3 #-----+-------#
| d
2 | |
v u
1 #---#>#-#
/
0 #-T
+ 0 1 2 3 4 5 6 7 8 9 0
1
"""
class TransitionToCave(xymap_legend.TransitionMapNode):
"""
A transition from 'the large tree' to 'the small cave' map. This node is never spawned
into a room but only acts as a target for finding the exit's destination.
"""
symbol = 'T'
target_map_xyz = (1, 0, 'the small cave')
# extends the default legend
LEGEND_MAP1 = {
'T': TransitionToCave
}
# link coordinates to rooms
PROTOTYPES_MAP1 = {
# node/room prototypes
(3, 0): {
"key": "Dungeon Entrance",
"desc": "To the east, a narrow opening leads into darkness."
},
(4, 1): {
"key": "Under the foilage of a giant tree",
"desc": "High above the branches of a giant tree blocks out the sunlight. A slide "
"leading down from the upper branches ends here."
},
(4, 4): {
"key": "The slide",
"desc": "A slide leads down to the ground from here. It looks like a one-way trip."
},
(6, 1): {
"key": "Thorny path",
"desc": "To the east is a pathway of thorns. If you get through, you don't think you'll be "
"able to get back here the same way."
},
(8, 1): {
"key": "By a large tree",
"desc": "You are standing at the root of a great tree."
},
(8, 3): {
"key": "At the top of the tree",
"desc": "You are at the top of the tree."
},
(3, 7): {
"key": "Dense foilage",
"desc": "The foilage to the east is extra dense. It will take forever to get through it."
},
(5, 6): {
"key": "On a huge branch",
"desc": "To the east is a glowing light, may be a teleporter to a higher branch."
},
(9, 7): {
"key": "On an enormous branch",
"desc": "To the west is a glowing light. It may be a teleporter to a lower branch."
},
(10, 8): {
"key": "A gorgeous view",
"desc": "The view from here is breathtaking, showing the forest stretching far and wide."
},
# default rooms
('*', '*'): {
"key": "Among the branches of a giant tree",
"desc": "These branches are wide enough to easily walk on. There's green all around."
},
# directional prototypes
(3, 0, 'e'): {
"desc": "A dark passage into the underworld."
},
}
for key, prot in PROTOTYPES_MAP1.items():
if len(key) == 2:
# we don't want to give exits the room typeclass!
prot['prototype_parent'] = ROOM_PARENT
else:
prot['prototype_parent'] = EXIT_PARENT
XYMAP_DATA_MAP1 = {
"zcoord": "the large tree",
"map": MAP1,
"legend": LEGEND_MAP1,
"prototypes": PROTOTYPES_MAP1
}
# -------------------------------------- map2
# The small cave
# this gives prototypes for every room
MAP2 = r"""
+ 0 1 2 3
3 #-#-#
|x|
2 #-#-#
| \
1 #---#
| /
0 T-#-#
+ 0 1 2 3
"""
# custom map node
class TransitionToLargeTree(xymap_legend.TransitionMapNode):
"""
A transition from 'the small cave' to 'the large tree' map. This node is never spawned
into a room by only acts as a target for finding the exit's destination.
"""
symbol = 'T'
target_map_xyz = (3, 0, 'the large tree')
# this extends the default legend (that defines #,-+ etc)
LEGEND_MAP2 = {
"T": TransitionToLargeTree
}
# prototypes for specific locations
PROTOTYPES_MAP2 = {
# node/rooms prototype overrides
(1, 0): {
"key": "The entrance",
"desc": "This is the entrance to a small cave leading into the ground. "
"Light sifts in from the outside, while cavernous passages disappear "
"into darkness."
},
(2, 0): {
"key": "A gruesome sight.",
"desc": "Something was killed here recently. The smell is unbearable."
},
(1, 1): {
"key": "A dark pathway",
"desc": "The path splits three ways here. To the north a faint light can be seen."
},
(3, 2): {
"key": "Stagnant water",
"desc": "A pool of stagnant, black water dominates this small chamber. To the nortwest "
"a faint light can be seen."
},
(0, 2): {
"key": "A dark alcove",
"desc": "This alcove is empty."
},
(1, 2): {
"key": "South-west corner of the atrium",
"desc": "Sunlight sifts down into a large underground chamber. Weeds and grass sprout "
"between the stones."
},
(2, 2): {
"key": "South-east corner of the atrium",
"desc": "Sunlight sifts down into a large underground chamber. Weeds and grass sprout "
"between the stones."
},
(1, 3): {
"key": "North-west corner of the atrium",
"desc": "Sunlight sifts down into a large underground chamber. Weeds and grass sprout "
"between the stones."
},
(2, 3): {
"key": "North-east corner of the atrium",
"desc": "Sunlight sifts down into a large underground chamber. Weeds and grass sprout "
"between the stones. To the east is a dark passage."
},
(3, 3): {
"key": "Craggy crevice",
"desc": "This is the deepest part of the dungeon. The path shrinks away and there "
"is no way to continue deeper."
},
# default fallback for undefined nodes
('*', '*'): {
"key": "A dark room",
"desc": "A dark, but empty, room."
},
# directional prototypes
(1, 0, 'w'): {
"desc": "A narrow path to the fresh air of the outside world."
},
# directional fallbacks for unset directions
('*', '*', '*'): {
"desc": "A dark passage"
}
}
# this is required by the prototypes, but we add it all at once so we don't
# need to add it to every line above
for key, prot in PROTOTYPES_MAP2.items():
if len(key) == 2:
# we don't want to give exits the room typeclass!
prot['prototype_parent'] = ROOM_PARENT
else:
prot['prototype_parent'] = EXIT_PARENT
XYMAP_DATA_MAP2 = {
"map": MAP2,
"zcoord": "the small cave",
"legend": LEGEND_MAP2,
"prototypes": PROTOTYPES_MAP2,
"options": {
"map_visual_range": 1,
"map_mode": 'scan'
}
}
# This is read by the parser
XYMAP_DATA_LIST = [
XYMAP_DATA_MAP1,
XYMAP_DATA_MAP2
]

View file

@ -0,0 +1,424 @@
"""
Custom Evennia launcher command option for maintaining the grid in a separate process than the main
server (since this can be slow).
To use, add to the settings:
::
EXTRA_LAUNCHER_COMMANDS.update({'xyzgrid': 'evennia.contrib.xyzgrid.launchcmd.xyzcommand'})
You should now be able to do
::
evennia xyzgrid <options>
Use `evennia xyzgrid help` for usage help.
"""
from os.path import join as pathjoin
from django.conf import settings
import evennia
from evennia.utils import ansi
from evennia.contrib.xyzgrid.xyzgrid import get_xyzgrid
_HELP_SHORT = """
evennia xyzgrid help | list | init | add | spawn | initpath | delete [<options>]
Manages the XYZ grid. Use 'xyzgrid help <option>' for documentation.
"""
_HELP_HELP = """
evennia xyzgrid <command> [<options>]
Manages the XYZ grid.
help <command> - get help about each command:
list - show list
init - initialize grid (only one time)
add - add new maps to grid
spawn - spawn added maps into actual db-rooms/exits
initpath - (re)creates pathfinder matrices
delete - delete part or all of grid
"""
_HELP_LIST = """
list
Lists the map grid structure and any loaded maps.
list <Z|mapname>
Display the given XYmap in more detail. Also 'show' works. Use quotes around
map-names with spaces.
Examples:
evennia xyzgrid list
evennia xyzgrid list mymap
evennia xyzgrid list "the small cave"
"""
_HELP_INIT = """
init
First start of the grid. This will create the XYZGrid global script. No maps are loaded yet!
It's safe to run this command multiple times; the grid will only be initialized once.
Example:
evennia xyzgrid init
"""
_HELP_ADD = """
add <path.to.xymap.module> [<path> <path>,...]
Add path(s) to one or more modules containing XYMap definitions. The module will be parsed
for
- a XYMAP_DATA - a dict on this form:
{"map": mapstring, "zcoord": mapname/zcoord, "legend": dict, "prototypes": dict}
describing one single XYmap, or
- a XYMAP_DATA_LIST - a list of multiple dicts on the XYMAP_DATA form. This allows for
embedding multiple maps in the same module. See evennia/contrib/xyzgrid/example.py
for an example of how this looks.
Note that adding a map does *not* spawn it. If maps are linked to one another, you should
add all linked maps before running 'spawn', or you'll get errors when creating transitional
exits between maps.
Examples:
evennia xyzgrid add evennia.contrib.xyzgrid.example
evennia xyzgrid add world.mymap1 world.mymap2 world.mymap3
"""
_HELP_SPAWN = """
spawn
spawns/updates the entire database grid based on the added maps. For a new grid, this will
spawn all new rooms/exits (and may take a good while!). For updating, rooms may be
removed/spawned if a map changed since the last spawn.
spawn "(X,Y,Z|mapname)"
spawns/updates only a part of the grid. Remember the quotes around the coordinate (this
is mostly because shells don't like them)! Use '*' as a wild card for XY coordinates.
This should usually only be used if the full grid has already been built once - otherwise
inter-map transitions may fail! Z is the name/z-coordinate of the map to spawn.
Examples:
evennia xyzgrid spawn - spawn all
evennia xyzgrid "(*, *, mymap1)" - spawn everything of map/zcoord mymap1
evennia xyzgrid "(12, 5, mymap1)" - spawn only coordinate (12, 5) on map/zcoord mymap1
"""
_HELP_INITPATH = """
initpath
Recreates the pathfinder matrices for the entire grid. These are used for all shortest-path
calculations. The result will be cached to disk (in mygame/server/.cache/). If not run, each
map will run this automatically first time it's used. Running this will always force to
respawn the cache.
initpath Z|mapname
recreate the pathfinder matrix for a specific map only. Z is the name/z-coordinate of the
map. If the map name has spaces in it, use quotes.
Examples:
evennia xyzgrid initpath
evennia xyzgrid initpath mymap1
evennia xyzgrid initpath "the small cave"
"""
_HELP_DELETE = """
delete
WARNING: This will delete the entire xyz-grid (all maps), and *all* rooms/exits built to
match it (they serve no purpose without the grid). You will be asked to confirm before
continuing with this operation.
delete Z|mapname
Remove a previously added XYmap with the name/z-coordinate Z. If the map was built, this
will also wipe all its spawned rooms/exits. You will be asked to confirm before continuing
with this operation. Use quotes if the Z/mapname contains spaces.
Examples:
evennia xyzgrid delete
evennia xyzgrid delete mymap1
evennia xyzgrid delete "the small cave"
"""
_TOPICS_MAP = {
"list": _HELP_LIST,
"init": _HELP_INIT,
"add": _HELP_ADD,
"spawn": _HELP_SPAWN,
"initpath": _HELP_INITPATH,
"delete": _HELP_DELETE
}
evennia._init()
def _option_help(*suboptions):
"""
Show help <command> aid.
"""
if not suboptions:
topic = _HELP_HELP
else:
topic = _TOPICS_MAP.get(suboptions[0], _HELP_HELP)
print(topic.strip())
def _option_list(*suboptions):
"""
List/view grid.
"""
xyzgrid = get_xyzgrid()
# override grid's logger to echo directly to console
def _log(msg):
print(msg)
xyzgrid.log = _log
xymap_data = xyzgrid.grid
if not xymap_data:
if xyzgrid.db.map_data:
print("Grid could not load due to errors.")
else:
print("The XYZgrid is currently empty. Use 'add' to add paths to your map data.")
return
if not suboptions:
print("XYMaps stored in grid:")
for zcoord, xymap in sorted(xymap_data.items(), key=lambda tup: tup[0]):
print("\n" + str(repr(xymap)) + ":\n")
print(ansi.parse_ansi(str(xymap)))
return
zcoord = " ".join(suboptions)
xymap = xyzgrid.get_map(zcoord)
if not xymap:
print(f"No XYMap with Z='{zcoord}' was found on grid.")
else:
nrooms = xyzgrid.get_room(('*', '*', zcoord)).count()
nnodes = len(xymap.node_index_map)
print("\n" + str(repr(xymap)) + ":\n")
checkwarning = True
if not nrooms:
print(f"{nrooms} / {nnodes} rooms are spawned.")
checkwarning = False
elif nrooms < nnodes:
print(f"{nrooms} / {nnodes} rooms are spawned\n"
"Note: Transitional nodes are *not* spawned (they just point \n"
"to another map), so the 'missing room(s)' may just be from such nodes.")
elif nrooms > nnodes:
print(f"{nrooms} / {nnodes} rooms are spawned\n"
"Note: Maybe some rooms were removed from map. Run 'spawn' to re-sync.")
else:
print(f"{nrooms} / {nnodes} rooms are spawned\n")
if checkwarning:
print("Note: This check is not complete; it does not consider changed map "
"topology\nlike relocated nodes/rooms and new/removed links/exits - this "
"is calculated only during a spawn.")
print("\nDisplayed map (as appearing in-game):\n\n" + ansi.parse_ansi(str(xymap)))
print("\nRaw map string (including axes and invisible nodes/links):\n"
+ str(xymap.mapstring))
print(f"\nCustom map options: {xymap.options}\n")
legend = []
for key, node_or_link in xymap.legend.items():
legend.append(f"{key} - {node_or_link.__doc__.strip()}")
print("Legend (all elements may not be present on map):\n " + "\n ".join(legend))
def _option_init(*suboptions):
"""
Initialize a new grid. Will fail if a Grid already exists.
"""
grid = get_xyzgrid()
print(f"The grid is initalized as the Script '{grid.key}'({grid.dbref})")
def _option_add(*suboptions):
"""
Add one or more map to the grid. Supports `add path,path,path,...`
"""
grid = get_xyzgrid()
# override grid's logger to echo directly to console
def _log(msg):
print(msg)
grid.log = _log
xymap_data_list = []
for path in suboptions:
maps = grid.maps_from_module(path)
if not maps:
print(f"No maps found with the path {path}.\nSeparate multiple paths with spaces. ")
return
mapnames = "\n ".join(f"'{m['zcoord']}'" for m in maps)
print(f" XYMaps from {path}:\n {mapnames}")
xymap_data_list.extend(maps)
grid.add_maps(*xymap_data_list)
try:
grid.reload()
except Exception as err:
print(err)
else:
print(f"Added (or readded) {len(xymap_data_list)} XYMaps to grid.")
def _option_spawn(*suboptions):
"""
spawn the grid or part of it.
"""
grid = get_xyzgrid()
# override grid's logger to echo directly to console
def _log(msg):
print(msg)
grid.log = _log
if suboptions:
opts = ''.join(suboptions).strip('()')
# coordinate tuple
try:
x, y, z = (part.strip() for part in opts.split(","))
except ValueError:
print("spawn coordinate must be given as (X, Y, Z) tuple, where '*' act "
"wild cards and Z is the mapname/z-coord of the map to load.")
return
else:
x, y, z = '*', '*', '*'
if x == y == z == '*':
inp = input("This will (re)spawn the entire grid. If it was built before, it may spawn \n"
"new rooms or delete rooms that no longer matches the grid.\nDo you want to "
"continue? [Y]/N? ")
else:
inp = input("This will spawn/delete objects in the database matching grid coordinates \n"
f"({x},{y},{z}) (where '*' is a wildcard).\nDo you want to continue? [Y]/N? ")
if inp.lower() in ('no', 'n'):
print("Aborted.")
return
print("Starting spawn ...")
grid.spawn(xyz=(x, y, z))
print("... spawn complete!\nIt's recommended to reload the server to refresh caches if this "
"modified an existing grid.")
def _option_initpath(*suboptions):
"""
(Re)Initialize the pathfinding matrices for grid or part of it.
"""
grid = get_xyzgrid()
# override grid's logger to echo directly to console
def _log(msg):
print(msg)
grid.log = _log
xymaps = grid.all_maps()
nmaps = len(xymaps)
for inum, xymap in enumerate(xymaps):
print(f"(Re)building pathfinding matrix for xymap Z={xymap.Z} ({inum+1}/{nmaps}) ...")
xymap.calculate_path_matrix(force=True)
cachepath = pathjoin(settings.GAME_DIR, "server", ".cache")
print(f"... done. Data cached to {cachepath}.")
def _option_delete(*suboptions):
"""
Delete the grid or parts of it. Allows mapname,mapname, ...
"""
grid = get_xyzgrid()
# override grid's logger to echo directly to console
def _log(msg):
print(msg)
grid.log = _log
if not suboptions:
repl = input("WARNING: This will delete the ENTIRE Grid and wipe all rooms/exits!"
"\nObjects/Chars inside deleted rooms will be moved to their home locations."
"\nThis can't be undone. Are you sure you want to continue? Y/[N]? ")
if repl.lower() not in ('yes', 'y'):
print("Aborted.")
return
print("Deleting grid ...")
grid.delete()
print("... done.\nPlease reload the server now; otherwise "
"removed rooms may linger in cache.")
return
zcoords = (part.strip() for part in suboptions)
err = False
for zcoord in zcoords:
if not grid.get_map(zcoord):
print(f"Mapname/zcoord {zcoord} is not a part of the grid.")
err = True
if err:
print("Valid mapnames/zcoords are\n:", "\n ".join(
xymap.Z for xymap in grid.all_rooms()))
return
repl = input("This will delete map(s) {', '.join(zcoords)} and wipe all corresponding\n"
"rooms/exits!"
"\nObjects/Chars inside deleted rooms will be moved to their home locations."
"\nThis can't be undone. Are you sure you want to continue? Y/[N]? ")
if repl.lower() not in ('yes', 'y'):
print("Aborted.")
return
print("Deleting selected xymaps ...")
grid.remove_map(*zcoords, remove_objects=True)
print("... done.\nPlease reload the server to refresh room caches."
"\nAlso remember to remove any links from remaining maps pointing to deleted maps.")
def xyzcommand(*args):
"""
Evennia launcher command. This is made available as `evennia xyzgrid` on the command line,
once added to `settings.EXTRA_LAUNCHER_COMMANDS`.
"""
if not args:
print(_HELP_SHORT.strip())
return
option, *suboptions = args
if option in ('help', 'h'):
_option_help(*suboptions)
if option in ('list', 'show'):
_option_list(*suboptions)
elif option == 'init':
_option_init(*suboptions)
elif option == 'add':
_option_add(*suboptions)
elif option == 'spawn':
_option_spawn(*suboptions)
elif option == 'initpath':
_option_initpath(*suboptions)
elif option == 'delete':
_option_delete(*suboptions)
else:
print(f"Unknown option '{option}'. Use 'evennia xyzgrid help' for valid arguments.")

View file

@ -0,0 +1,47 @@
"""
Default prototypes for building the XYZ-grid into actual game-rooms.
Add this to mygame/conf/settings/settings.py:
PROTOTYPE_MODULES += ['evennia.contrib.xyzgrid.prototypes']
The prototypes can then be used in mapping prototypes as
{'prototype_parent': 'xyz_room', ...}
and/or
{'prototype_parent': 'xyz_exit', ...}
"""
from django.conf import settings
try:
room_override = settings.XYZROOM_PROTOTYPE_OVERRIDE
except AttributeError:
room_override = {}
try:
exit_override = settings.XYZEXIT_PROTOTYPE_OVERRIDE
except AttributeError:
exit_override = {}
room_prototype = {
'prototype_key': 'xyz_room',
'typeclass': 'evennia.contrib.xyzgrid.xyzroom.XYZRoom',
'prototype_tags': ("xyzroom", ),
'key': "A room",
'desc': "An empty room."
}
room_prototype.update(room_override)
exit_prototype = {
'prototype_key': 'xyz_exit',
'typeclass': 'evennia.contrib.xyzgrid.xyzroom.XYZExit',
'prototype_tags': ("xyzexit", ),
'desc': "An exit."
}
exit_prototype.update(exit_override)
# accessed by the prototype importer
PROTOTYPE_LIST = [room_prototype, exit_prototype]

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,55 @@
"""
Helpers and resources for the map system.
"""
BIGVAL = 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),
}
# errors for Map system
class MapError(RuntimeError):
def __init__(self, error="", node_or_link=None):
prefix = ""
if node_or_link:
prefix = (f"{node_or_link.__class__.__name__} '{node_or_link.symbol}' "
f"at XYZ=({node_or_link.X:g},{node_or_link.Y:g},{node_or_link.Z}) ")
self.node_or_link = node_or_link
self.message = f"{prefix}{error}"
super().__init__(self.message)
class MapParserError(MapError):
pass
class MapTransition(RuntimeWarning):
"""
Used when signaling to the parser that a link
leads to another map.
"""
pass

View file

@ -0,0 +1,945 @@
r"""
# XYMap
The `XYMap` class represents one XY-grid of interconnected map-legend components. It's built from an
ASCII representation, where unique characters represents each type of component. The Map parses the
map into an internal graph that can be efficiently used for pathfinding the shortest route between
any two nodes (rooms).
Each room (MapNode) can have exits (links) in 8 cardinal directions (north, northwest etc) as well
as up and down. These are indicated in code as 'n', 'ne', 'e', 'se', 's', 'sw', 'w',
'nw', 'u' and 'd'.
```python
# in module passed to 'Map' class
MAP = r'''
1
+ 0 1 2 3 4 5 6 7 8 9 0
10 # # # # #-I-#
\ i i i d
9 #-#-#-# |
|\ | u
8 #-#-#-#-----#b----o
| | |
7 #-#---#-#-#-#-# |
| |x|x| |
6 o-#-#-# #-#-#-#b#
\ |x|x|
5 o---#-#<--#-#-#
/ |
4 #-----+-# #---#
\ | | \ /
3 #b#-#-# x #
| | / \ u
2 #-#-#---#
^ d
1 #-# #
|
0 #-#---o
+ 0 1 2 3 4 5 6 7 8 9 1
0
'''
LEGEND = {'#': xyzgrid.MapNode, '|': xyzgrid.NSMapLink,...}
# read by parser if XYMAP_DATA_LIST doesn't exist
XYMAP_DATA = {
"map": MAP,
"legend": LEGEND,
"zcoord": "City of Foo",
"prototypes": {
(0,1): { ... },
(1,3): { ... },
...
}
}
# will be parsed first, allows for multiple map-data dicts from one module
XYMAP_DATA_LIST = [
XYMAP_DATA
]
```
The two `+` signs in the upper/lower left corners are required and marks the edge of the map area.
The origo of the grid is always two steps right and two up from the bottom test marker and the grid
extends to two lines below the top-left marker. Anything outside the grid is ignored, so numbering
the coordinate axes is optional but recommended for readability.
The XY positions represent coordinates positions in the game world. When existing, they are usually
represented by Rooms in-game. The links between nodes would normally represent Exits, but the length
of links on the map have no in-game equivalence except that traversing a multi-step link will place
you in a location with an XY coordinate different from what you'd expect by a single step (most
games don't relay the XY position to the player anyway).
In the map string, every XY coordinate must have exactly one spare space/line between them - this is
used for node linkings. This finer grid which has 2x resolution of the `XYgrid` is only used by the
mapper and is referred to as the `xygrid` (small xy) internally. Note that an XY position can also
be held by a link (for example a passthrough).
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. A `MapNode` can only be added
on an even XY coordinate while `MapLink`s can be added anywhere on the xygrid.
See `./example.py` for a full grid example.
----
"""
import pickle
from collections import defaultdict
from os import mkdir
from os.path import isdir, isfile, join as pathjoin
try:
from scipy.sparse.csgraph import dijkstra
from scipy.sparse import csr_matrix
from scipy import zeros
except ImportError as err:
raise ImportError(
f"{err}\nThe XYZgrid contrib requires "
"the SciPy package. Install with `pip install scipy'.")
from django.conf import settings
from evennia.utils.utils import variable_from_module, mod_import, is_iter
from evennia.utils import logger
from evennia.prototypes import prototypes as protlib
from evennia.prototypes.spawner import flatten_prototype
from .utils import MapError, MapParserError, BIGVAL
from . import xymap_legend
_NO_DB_PROTOTYPES = True
if hasattr(settings, "XYZGRID_USE_DB_PROTOTYPES"):
_NO_DB_PROTOTYPES = not settings.XYZGRID_USE_DB_PROTOTYPES
_CACHE_DIR = settings.CACHE_DIR
_LOADED_PROTOTYPES = None
_XYZROOMCLASS = None
MAP_DATA_KEYS = [
"zcoord", "map", "legend", "prototypes", "options", "module_path"
]
DEFAULT_LEGEND = xymap_legend.LEGEND
# --------------------------------------------
# Map parser implementation
class XYMap:
r"""
This represents a single map of interconnected nodes/rooms, parsed from a ASCII map
representation.
Each room is connected to each other as a directed graph with optional 'weights' between the the
connections. It is created from a map string with symbols describing the topological layout. It
also provides pathfinding using the Dijkstra algorithm.
The map-string is read from a string or from a module. The grid area of the string is marked by
two `+` characters - one in the top left of the area and the other in the bottom left.
The grid starts two spaces/lines in from the 'open box' created by these two markers and extend
any width to the right.
Any other markers or comments can be added outside of the grid - they will be ignored. Every
grid coordinate must always be separated by exactly one space/line since the space between
are used for links.
::
'''
1 1 1
+ 0 1 2 3 4 5 6 7 8 9 0 1 2 ...
4 # # #
| \ /
3 #-#-# # #
| \ /
2 #-#-# #
|x|x| |
1 #-#-#-#-#-#-#
/
0 #-#
+ 0 1 2 3 4 5 6 7 8 9 1 1 1 ...
0 1 2
'''
So origo (0,0) is in the bottom-left and north is +y movement, south is -y movement
while east/west is +/- x movement as expected. Adding numbers to axes is optional
but recommended for readability!
"""
mapcorner_symbol = '+'
max_pathfinding_length = 500
empty_symbol = ' '
# we normally only accept one single character for the legend key
legend_key_exceptions = ("\\")
def __init__(self, map_module_or_dict, Z="map", xyzgrid=None):
"""
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 MAP_DATA key 'map' and optionally a 'legend'
dicts to specify the map structure.
Z (int or str, optional): Name or Z-coord for for this map. Needed if the game uses
more than one map. If not given, it can also be embedded in the
`map_module_or_dict`. Used when referencing this map during map transitions,
baking of pathfinding matrices etc.
xyzgrid (.xyzgrid.XYZgrid): A top-level grid this map is a part of.
Notes:
Interally, the map deals with two sets of coordinate systems:
- grid-coordinates x,y are the character positions in the map string.
- world-coordinates X,Y are the in-world coordinates of nodes/rooms.
There are fewer of these since they ignore the 'link' spaces between
the nodes in the grid, s
X = x // 2
Y = y // 2
- The Z-coordinate, if given, is only used when transitioning between maps
on the supplied `grid`.
"""
global _LOADED_PROTOTYPES
if not _LOADED_PROTOTYPES:
# inject default prototypes, but don't override prototype-keys loaded from
# settings, if they exist (that means the user wants to replace the defaults)
protlib.load_module_prototypes("evennia.contrib.xyzgrid.prototypes", override=False)
_LOADED_PROTOTYPES = True
self.Z = Z
self.xyzgrid = xyzgrid
self.mapstring = ""
self.raw_mapstring = ""
# store so we can reload
self.map_module_or_dict = map_module_or_dict
self.prototypes = None
self.options = None
# transitional mapping
self.symbol_map = None
# map setup
self.xygrid = None
self.XYgrid = None
self.display_map = None
self.max_x = 0
self.max_y = 0
self.max_X = 0
self.max_Y = 0
# Dijkstra algorithm variables
self.node_index_map = None
self.dist_matrix = None
self.pathfinding_routes = None
self.pathfinder_baked_filename = None
if Z:
if not isdir(_CACHE_DIR):
mkdir(_CACHE_DIR)
self.pathfinder_baked_filename = pathjoin(_CACHE_DIR, f"{Z}.P")
# load data and parse it
self.reload()
def __str__(self):
"""
Print the string representation of the map.
Since the y-axes origo is at the bottom, we must flip the
y-axis before printing (since printing is always top-to-bottom).
"""
return "\n".join("".join(line) for line in self.display_map[::-1])
def __repr__(self):
nnodes = 0
if self.node_index_map:
nnodes = len(self.node_index_map)
return (f"<XYMap(Z={self.Z}), {self.max_X + 1}x{self.max_Y + 1}, {nnodes} nodes>")
def log(self, msg):
if self.xyzgrid:
self.xyzgrid.log(msg)
else:
logger.log_info(msg)
def reload(self, map_module_or_dict=None):
"""
(Re)Load a map.
Args:
map_module_or_dict (str, module or dict, optional): See description for the variable
in the class' `__init__` function. If given, replace the already loaded
map with a new one. If not given, the existing one given on class creation
will be reloaded.
parse (bool, optional): If set, auto-run `.parse()` on the newly loaded data.
Notes:
This will both (re)load the data and parse it into a new map structure, replacing any
existing one. The valid mapstructure is:
::
{
"map": <str>,
"zcoord": <int or str>, # optional
"legend": <dict>, # optional
"prototypes": <dict> # optional
"options": <dict> # optional
}
"""
if not map_module_or_dict:
map_module_or_dict = self.map_module_or_dict
mapdata = {}
if isinstance(map_module_or_dict, dict):
# map-structure provided directly
mapdata = map_module_or_dict
else:
# read from contents of module
mod = mod_import(map_module_or_dict)
mapdata_list = variable_from_module(mod, "XYMAP_DATA_LIST")
if mapdata_list and self.Z:
# use the stored Z value to figure out which map data we want
mapping = {mapdata.get("zcoord") for mapdata in mapdata_list}
mapdata = mapping.get(self.Z, {})
if not mapdata:
mapdata = variable_from_module(mod, "XYMAP_DATA")
if not mapdata:
raise MapError("No valid XYMAP_DATA or XYMAP_DATA_LIST could be found from "
f"{map_module_or_dict}.")
# validate
if any(key for key in mapdata if key not in MAP_DATA_KEYS):
raise MapError(f"Mapdata has keys {list(mapdata)}, but only "
f"keys {MAP_DATA_KEYS} are allowed.")
for key in mapdata.get('legend', DEFAULT_LEGEND):
if not key or len(key) > 1:
if key not in self.legend_key_exceptions:
raise MapError(f"Map-legend key '{key}' is invalid: All keys must "
"be exactly one character long. Use the node/link's "
"`.display_symbol` property to change how it is "
"displayed.")
if 'map' not in mapdata or not mapdata['map']:
raise MapError("No map found. Add 'map' key to map-data dict.")
for key, prototype in mapdata.get('prototypes', {}).items():
if not (is_iter(key) and (2 <= len(key) <= 3)):
raise MapError(f"Prototype override key {key} is malformed: It must be a "
"coordinate (X, Y) for nodes or (X, Y, direction) for links; "
"where direction is a supported direction string ('n', 'ne', etc).")
# store/update result
self.Z = mapdata.get('zcoord', self.Z)
self.mapstring = mapdata['map']
self.prototypes = mapdata.get('prototypes', {})
self.options = mapdata.get('options', {})
# merge the custom legend onto the default legend to allow easily
# overriding only parts of it
self.legend = {**DEFAULT_LEGEND, **map_module_or_dict.get("legend", DEFAULT_LEGEND)}
# initialize any prototypes on the legend entities
for char, node_or_link_class in self.legend.items():
prototype = node_or_link_class.prototype
if not prototype or isinstance(prototype, dict):
# nothing more to do
continue
# we need to load the prototype dict onto each for ease of access. Note that
proto = protlib.search_prototype(prototype, require_single=True,
no_db=_NO_DB_PROTOTYPES)[0]
node_or_link_class.prototype = proto
def parse(self):
"""
Parses the numerical grid from the string. The first pass means parsing out
all nodes. The linking-together of nodes is not happening until the second pass
(the reason for this is that maps can also link to other maps, so all maps need
to have gone through their first parsing-passes before they can be linked together).
See the class docstring for details of how the grid should be defined.
Notes:
In this parsing, the 'xygrid' is the full range of chraracters read from
the string. The `XYgrid` is used to denote the game-world coordinates
(which doesn't include the links)
"""
mapcorner_symbol = self.mapcorner_symbol
# this allows for string-based [x][y] mapping with arbitrary objects
xygrid = defaultdict(dict)
# mapping nodes to real X,Y positions
XYgrid = defaultdict(dict)
# needed by pathfinder
node_index_map = {}
# used by transitions
symbol_map = defaultdict(list)
mapstring = self.mapstring
if mapstring.count(mapcorner_symbol) < 2:
raise MapParserError(
f"The mapstring must have at least two '{mapcorner_symbol}' "
"symbols marking the upper- and bottom-left corners of the "
"grid area.")
# 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
for topleft_marker_y, line in enumerate(maplines):
topleft_marker_x = line.find(mapcorner_symbol)
if topleft_marker_x != -1:
break
if -1 in (topleft_marker_x, topleft_marker_y):
raise MapParserError(f"No top-left corner-marker ({mapcorner_symbol}) found!")
# find the position (in the string as a whole) of the bottom-left corner-marker
# this is always in a stright line down from the first marker
botleft_marker_x, botleft_marker_y = topleft_marker_x, -1
for botleft_marker_y, line in enumerate(maplines[topleft_marker_y + 1:]):
if line.find(mapcorner_symbol) == topleft_marker_x:
break
if botleft_marker_y == -1:
raise MapParserError(f"No bottom-left corner-marker ({mapcorner_symbol}) found! "
"Make sure it lines up with the top-left corner-marker "
f"(found at column {topleft_marker_x} of the string).")
# the actual coordinate is dy below the topleft marker so we need to shift
botleft_marker_y += topleft_marker_y + 1
# in-string_position of the top- and bottom-left grid corners (2 steps in from marker)
# the bottom-left corner is also the origo (0,0) of the grid.
topleft_y = topleft_marker_y + 2
origo_x, origo_y = botleft_marker_x + 2, botleft_marker_y - 1
# highest actually filled grid points
max_x = 0
max_y = 0
max_X = 0
max_Y = 0
node_index = -1
# first pass: read string-grid (left-right, bottom-up) and parse all grid points
for iy, line in enumerate(reversed(maplines[topleft_y:origo_y])):
even_iy = iy % 2 == 0
for ix, char in enumerate(line[origo_x:]):
# from now on, coordinates are on the xygrid.
if char == self.empty_symbol:
continue
# only set this if there's actually something on the line
max_x, max_y = max(max_x, ix), max(max_y, iy)
mapnode_or_link_class = self.legend.get(char)
if not mapnode_or_link_class:
raise MapParserError(
f"Symbol '{char}' on XY=({ix / 2:g},{iy / 2:g}) "
"is not found in LEGEND."
)
if hasattr(mapnode_or_link_class, "node_index"):
# A mapnode. Mapnodes can only be placed on even grid positions, where
# there are integer X,Y coordinates defined.
if not (even_iy and ix % 2 == 0):
raise MapParserError(
f"Symbol '{char}' on XY=({ix / 2:g},{iy / 2:g}) marks a "
"MapNode but is located between integer (X,Y) positions (only "
"Links can be placed between coordinates)!")
# save the node to several different maps for different uses
# in both coordinate systems
iX, iY = ix // 2, iy // 2
max_X, max_Y = max(max_X, iX), max(max_Y, iY)
node_index += 1
xygrid[ix][iy] = XYgrid[iX][iY] = node_index_map[node_index] = \
mapnode_or_link_class(x=ix, y=iy, Z=self.Z,
node_index=node_index, symbol=char, xymap=self)
else:
# we have a link at this xygrid position (this is ok everywhere)
xygrid[ix][iy] = mapnode_or_link_class(x=ix, y=iy, Z=self.Z, symbol=char,
xymap=self)
# store the symbol mapping for transition lookups
symbol_map[char].append(xygrid[ix][iy])
# store before building links
self.max_x, self.max_y = max_x, max_y
self.max_X, self.max_Y = max_X, max_Y
self.xygrid = xygrid
self.XYgrid = XYgrid
self.node_index_map = node_index_map
self.symbol_map = symbol_map
# build all links
for node in node_index_map.values():
node.build_links()
# build display 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()
for node in node_index_map.values():
# override node-prototypes, ignore if no prototype
# is defined (some nodes should not be spawned)
if node.prototype:
node_coord = (node.X, node.Y)
# load prototype from override, or use default
try:
node.prototype = flatten_prototype(self.prototypes.get(
node_coord,
self.prototypes.get(('*', '*'), node.prototype)),
no_db=_NO_DB_PROTOTYPES
)
except Exception as err:
raise MapParserError(f"Room prototype malformed: {err}", node)
# do the same for links (x, y, direction) coords
for direction, maplink in node.first_links.items():
try:
maplink.prototype = flatten_prototype(self.prototypes.get(
node_coord + (direction,),
self.prototypes.get(('*', '*', '*'), maplink.prototype)),
no_db=_NO_DB_PROTOTYPES
)
except Exception as err:
raise MapParserError(f"Exit prototype malformed: {err}", maplink)
# store
self.display_map = display_map
def _get_topology_around_coord(self, xy, dist=2):
"""
Get all links and nodes up to a certain distance from an XY coordinate.
Args:
xy (tuple), the X,Y coordinate of the center point.
dist (int): How many nodes away from center point to find paths for.
Returns:
tuple: A tuple of 5 elements `(xy_coords, xmin, xmax, ymin, ymax)`, where the
first element is a list of xy-coordinates (on xygrid) for all linked nodes within
range. This is meant to be used with the xygrid for extracting a subset
for display purposes. The others are the minimum size of the rectangle
surrounding the area containing `xy_coords`.
Notes:
This performs a depth-first pass down the the given dist.
"""
def _scan_neighbors(start_node, points, dist=2,
xmin=BIGVAL, ymin=BIGVAL, xmax=0, ymax=0, depth=0):
x0, y0 = start_node.x, start_node.y
points.append((x0, y0))
xmin, xmax = min(xmin, x0), max(xmax, x0)
ymin, ymax = min(ymin, y0), max(ymax, y0)
if depth < dist:
# keep stepping
for direction, end_node in start_node.links.items():
x, y = x0, y0
for link in start_node.xy_steps_to_node[direction]:
x, y = link.x, link.y
points.append((x, y))
xmin, xmax = min(xmin, x), max(xmax, x)
ymin, ymax = min(ymin, y), max(ymax, y)
points, xmin, xmax, ymin, ymax = _scan_neighbors(
end_node, points, dist=dist,
xmin=xmin, ymin=ymin, xmax=xmax, ymax=ymax,
depth=depth + 1)
return points, xmin, xmax, ymin, ymax
center_node = self.get_node_from_coord(xy)
points, xmin, xmax, ymin, ymax = _scan_neighbors(center_node, [], dist=dist)
return list(set(points)), xmin, xmax, ymin, ymax
def calculate_path_matrix(self, force=False):
"""
Solve the pathfinding problem using Dijkstra's algorithm. This will try to
load the solution from disk if possible.
Args:
force (bool, optional): If the cache should always be rebuilt.
"""
if not force and self.pathfinder_baked_filename and isfile(self.pathfinder_baked_filename):
# check if the solution for this grid was already solved previously.
mapstr, dist_matrix, pathfinding_routes = "", None, None
with open(self.pathfinder_baked_filename, 'rb') as fil:
try:
mapstr, dist_matrix, pathfinding_routes = pickle.load(fil)
except Exception:
logger.log_trace()
if (mapstr == self.mapstring
and dist_matrix is not None
and pathfinding_routes is not None):
# this is important - it means the map hasn't changed so
# we can re-use the stored data!
self.dist_matrix = dist_matrix
self.pathfinding_routes = pathfinding_routes
# build a matrix representing the map graph, with 0s as impassable areas
nnodes = len(self.node_index_map)
pathfinding_graph = zeros((nnodes, nnodes))
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=self.max_pathfinding_length)
if self.pathfinder_baked_filename:
# try to cache the results
with open(self.pathfinder_baked_filename, 'wb') as fil:
pickle.dump((self.mapstring, self.dist_matrix, self.pathfinding_routes),
fil, protocol=4)
def spawn_nodes(self, xy=('*', '*')):
"""
Convert the nodes of this XYMap into actual in-world rooms by spawning their
related prototypes in the correct coordinate positions. This must be done *first*
before spawning links (with `spawn_links` because exits require the target destination
to exist. It's also possible to only spawn a subset of the map
Args:
xy (tuple, optional): An (X,Y) coordinate of node(s). `'*'` acts as a wildcard.
Examples:
- `xy=(1, 3) - spawn (1,3) coordinate only.
- `xy=('*', 1) - spawn all nodes in the first row of the map only.
- `xy=('*', '*')` - spawn all nodes
Returns:
list: A list of nodes that were spawned.
"""
global _XYZROOMCLASS
if not _XYZROOMCLASS:
from evennia.contrib.xyzgrid.xyzroom import XYZRoom as _XYZROOMCLASS
x, y = xy
wildcard = '*'
spawned = []
# find existing nodes, in case some rooms need to be removed
map_coords = [(node.X, node.Y) for node in
sorted(self.node_index_map.values(), key=lambda n: (n.Y, n.X))]
for existing_room in _XYZROOMCLASS.objects.filter_xyz(xyz=(x, y, self.Z)):
roomX, roomY, _ = existing_room.xyz
if (roomX, roomY) not in map_coords:
self.log(f" deleting room at {existing_room.xyz} (not found on map).")
existing_room.delete()
# (re)build nodes (will not build already existing rooms)
for node in sorted(self.node_index_map.values(), key=lambda n: (n.Y, n.X)):
if (x in (wildcard, node.X)) and (y in (wildcard, node.Y)):
node.spawn()
spawned.append(node)
return spawned
def spawn_links(self, xy=('*', '*'), nodes=None, directions=None):
"""
Convert links of this XYMap into actual in-game exits by spawning their related
prototypes. It's possible to only spawn a specic exit by specifying the node and
Args:
xy (tuple, optional): An (X,Y) coordinate of node(s). `'*'` acts as a wildcard.
nodes (list, optional): If given, only consider links out of these nodes. This also
affects `xy`, so that if there are no nodes of given coords in `nodes`, no
links will be spawned at all.
directions (list, optional): A list of cardinal directions ('n', 'ne' etc). If given,
sync only the exit in the given directions (`xy` limits which links out of which
nodes should be considered). If unset, there are no limits to directions.
Examples:
- `xy=(1, 3 )`, `direction='ne'` - sync only the north-eastern exit
out of the (1, 3) node.
"""
x, y = xy
wildcard = '*'
if not nodes:
nodes = sorted(self.node_index_map.values(), key=lambda n: (n.Z, n.Y, n.X))
for node in nodes:
if (x in (wildcard, node.X)) and (y in (wildcard, node.Y)):
node.spawn_links(directions=directions)
def get_node_from_coord(self, xy):
"""
Get a MapNode from a coordinate.
Args:
xy (tuple): X,Y coordinate on XYgrid.
Returns:
MapNode: The node found at the given coordinates. Returns
`None` if there is no mapnode at the given coordinate.
Raises:
MapError: If trying to specify an iX,iY outside
of the grid's maximum bounds.
"""
if not self.XYgrid:
self.parse()
iX, iY = xy
if not ((0 <= iX <= self.max_X) and (0 <= iY <= self.max_Y)):
raise MapError(f"get_node_from_coord got coordinate {xy} which is "
f"outside the grid size of (0,0) - ({self.max_X}, {self.max_Y}).")
try:
return self.XYgrid[iX][iY]
except KeyError:
return None
def get_components_with_symbol(self, symbol):
"""
Find all map components (nodes, links) with a given symbol in this map.
Args:
symbol (char): A single character-symbol to search for.
Returns:
list: A list of MapNodes and/or MapLinks found with the matching symbol.
"""
return self.symbol_map.get(symbol, [])
def get_shortest_path(self, start_xy, end_xy):
"""
Get the shortest route between two points on the grid.
Args:
start_xy (tuple): A starting (X,Y) coordinate on the XYgrid (in-game coordinate) for
where we start from.
end_xy (tuple or MapNode): The end (X,Y) coordinate on the XYgrid (in-game coordinate)
we want to find the shortest route to.
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 all MapLinks in a sequence describing
the full path including the start- and end-node.
"""
startnode = self.get_node_from_coord(start_xy)
endnode = self.get_node_from_coord(end_xy)
if not (startnode and endnode):
# no node at given coordinate. No path is possible.
return [], []
try:
istartnode = startnode.node_index
inextnode = endnode.node_index
except AttributeError:
raise MapError(f"Map.get_shortest_path received start/end nodes {startnode} and "
f"{endnode}. They must both be MapNodes (not Links)")
if self.pathfinding_routes is None:
self.calculate_path_matrix()
pathfinding_routes = self.pathfinding_routes
node_index_map = self.node_index_map
path = [endnode]
directions = []
while pathfinding_routes[istartnode, inextnode] != -9999:
# the -9999 is set by algorithm for unreachable nodes or if trying
# to go a node we are already at (the start node in this case since
# 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][::-1] + [nextnode])
# we have the path - reverse to get the correct order
directions = directions[::-1]
path = path[::-1]
return directions, path
def get_visual_range(self, xy, dist=2, mode='nodes',
character='@',
target=None,
target_path_style="|y{display_symbol}|n",
max_size=None,
indent=0,
return_str=True):
"""
Get a part of the grid centered on a specific point and extended a certain number
of nodes or grid points in every direction.
Args:
xy (tuple): (X,Y) in-world coordinate location. If this is not the location
of a node on the grid, the `character` or the empty-space symbol (by default
an empty space) will be shown.
dist (int, optional): Number of gridpoints distance to show. Which
grid to use depends on the setting of `only_nodes`. Set to `None` to
always show the entire grid.
mode (str, optional): One of 'scan' or 'nodes'. In 'scan' mode, dist measure
number of xy grid points in all directions and doesn't care about if visible
nodes are reachable or not. If 'nodes', distance measure how many linked nodes
away from the center coordinate to display.
character (str, optional): Place this symbol at the `xy` position
of the displayed map. The center node's symbol is shown if this is falsy.
target (tuple, optional): A target XY coordinate to go to. The path to this
(or the beginning of said path, if outside of visual range) will be
marked according to `target_path_style`.
target_path_style (str or callable, optional): This is use for marking the path
found when `target` is given. If a string, it accepts a formatting marker
`display_symbol` which will be filled with the `display_symbol` of each node/link
the path passes through. This allows e.g. to color the path. If a callable, this
will receive the MapNode or MapLink object for every step of the path and and
must return the suitable string to display at the position of the node/link.
max_size (tuple, optional): A max `(width, height)` to crop the displayed
return to. Make both odd numbers to get a perfect center. Set either of
the tuple values to `None` to make that coordinate unlimited. Set entire
tuple to None let display-size able to grow up to full size of grid.
indent (int, optional): How far to the right to indent the map area (only
applies to `return_str=True`).
return_str (bool, optional): Return result as an already formatted string
or a 2D list.
Returns:
str or list: Depending on value of `return_str`. If a list,
this is 2D grid of lines, [[str,str,str,...], [...]] where
each element is a single character in the display grid. To
extract a character at (ix,iy) coordinate from it, use
indexing `outlist[iy][ix]` in that order.
Notes:
If outputting a list, the y-axis must first be reversed before printing since printing
happens top-bottom and the y coordinate system goes bottom-up. This can be done simply
with this before building the final string to send/print.
printable_order_list = outlist[::-1]
If mode='nodes', a `dist` of 2 will give the following result in a row of nodes:
#-#-@----------#-#
This display may thus visually grow much bigger than expected (both horizontally and
vertically). consider setting `max_size` if wanting to restrict the display size. Also
note that link 'weights' are *included* in this estimate, so if links have weights > 1,
fewer nodes may be found for a given `dist`.
If mode=`scan`, a dist of 2 on the above example would instead give
#-@--
This mode simply shows a cut-out subsection of the map you are on. The `dist` is
measured on xygrid, so two steps per XY coordinate. It does not consider links or
weights and may also show nodes not actually reachable at the moment:
| |
# @-#
"""
iX, iY = xy
# convert inputs to xygrid
width, height = self.max_x + 1, self.max_y + 1
ix, iy = max(0, min(iX * 2, width)), max(0, min(iY * 2, height))
display_map = self.display_map
xmin, xmax, ymin, ymax = 0, width - 1, 0, height - 1
if dist is None:
# show the entire grid
gridmap = self.display_map
ixc, iyc = ix, iy
elif dist is None or dist <= 0 or not self.get_node_from_coord(xy):
# There is no node at these coordinates. Show
# nothing but ourselves or emptiness
return character if character else self.empty_symbol
elif mode == 'nodes':
# dist measures only full, reachable nodes.
points, xmin, xmax, ymin, ymax = self._get_topology_around_coord(xy, dist=dist)
ixc, iyc = ix - xmin, iy - ymin
# note - override width/height here since our grid is
# now different from the original for future cropping
width, height = xmax - xmin + 1, ymax - ymin + 1
gridmap = [[" "] * width for _ in range(height)]
for (ix0, iy0) in points:
gridmap[iy0 - ymin][ix0 - xmin] = display_map[iy0][ix0]
elif mode == 'scan':
# scan-mode - dist measures individual grid points
xmin, xmax = max(0, ix - dist), min(width, ix + dist + 1)
ymin, ymax = max(0, iy - dist), min(height, iy + dist + 1)
ixc, iyc = ix - xmin, iy - ymin
gridmap = [line[xmin:xmax] for line in display_map[ymin:ymax]]
else:
raise MapError(f"Map.get_visual_range 'mode' was '{mode}' "
"- it must be either 'scan' or 'nodes'.")
if character:
gridmap[iyc][ixc] = character # correct indexing; it's a list of lines
if target:
# stylize path to target
def _default_callable(node):
return target_path_style.format(
display_symbol=node.get_display_symbol())
if callable(target_path_style):
_target_path_style = target_path_style
else:
_target_path_style = _default_callable
_, path = self.get_shortest_path(xy, target)
maxstep = dist if mode == 'nodes' else dist / 2
nsteps = 0
for node_or_link in path[1:]:
if hasattr(node_or_link, "node_index"):
nsteps += 1
if nsteps > maxstep:
break
# don't decorate current (character?) location
ix, iy = node_or_link.x, node_or_link.y
if xmin <= ix <= xmax and ymin <= iy <= ymax:
gridmap[iy - ymin][ix - xmin] = _target_path_style(node_or_link)
if max_size:
# crop grid to make sure it doesn't grow too far
max_x, max_y = max_size
max_x = self.max_x if max_x is None else max_x
max_y = self.max_y if max_y is None else max_y
xmin, xmax = max(0, ixc - max_x // 2), min(width, ixc + max_x // 2 + 1)
ymin, ymax = max(0, iyc - max_y // 2), min(height, iyc + max_y // 2 + 1)
gridmap = [line[xmin:xmax] for line in gridmap[ymin:ymax]]
if return_str:
# we must flip the y-axis before returning the string
indent = indent * " "
return indent + f"\n{indent}".join("".join(line) for line in gridmap[::-1])
else:
return gridmap

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,308 @@
"""
The grid
This represents the full XYZ grid, which consists of
- 2D `Map`-objects parsed from Map strings and Map-legend components. Each represents one
Z-coordinate or location.
- `Prototypes` for how to build each XYZ component into 'real' rooms and exits.
- Actual in-game rooms and exits, mapped to the game based on Map data.
The grid has three main functions:
- Building new rooms/exits from scratch based on one or more Maps.
- Updating the rooms/exits tied to an existing Map when the Map string
of that map changes.
- Fascilitate communication between the in-game entities and their Map.
"""
from evennia.scripts.scripts import DefaultScript
from evennia.utils import logger
from evennia.utils.utils import variable_from_module, make_iter
from .xymap import XYMap
from .xyzroom import XYZRoom, XYZExit
class XYZGrid(DefaultScript):
"""
Main grid class. This organizes the Maps based on their name/Z-coordinate.
"""
def at_script_creation(self):
"""
What we store persistently is data used to create each map (the legends, names etc)
"""
self.db.map_data = {}
self.desc = "Manages maps for XYZ-grid"
@property
def grid(self):
if self.ndb.grid is None:
self.reload()
return self.ndb.grid
def get_map(self, zcoord):
"""
Get a specific xymap.
Args:
zcoord (str): The name/zcoord of the xymap.
Returns:
XYMap: Or None if no map was found.
"""
return self.grid.get(zcoord)
def all_maps(self):
"""
Get all xymaps stored in the grid.
Returns:
list: All initialized xymaps stored with this grid.
"""
return list(self.grid.values())
def log(self, msg):
logger.log_info(f"|grid| {msg}")
def get_room(self, xyz, **kwargs):
"""
Get one or more room objects from XYZ coordinate.
Args:
xyz (tuple): X,Y,Z coordinate of room to fetch. '*' acts
as wild cards.
Returns:
Queryset: A queryset of XYZRoom(s) found.
Raises:
XYZRoom.DoesNotExist: If room is not found.
Notes:
This assumes the room was previously built.
"""
return XYZRoom.objects.filter_xyz(xyz=xyz, **kwargs)
def get_exit(self, xyz, name='north', **kwargs):
"""
Get one or more exit object at coordinate.
Args:
xyz (tuple): X,Y,Z coordinate of the room the
exit leads out of. '*' acts as a wildcard.
name (str): The full name of the exit, e.g. 'north' or 'northwest'.
The '*' acts as a wild card.
Returns:
Queryset: A queryset of XYZExit(s) found.
"""
kwargs['db_key'] = name
return XYZExit.objects.filter_xyz_exit(xyz=xyz, **kwargs)
def maps_from_module(self, module_path):
"""
Load map data from module. The loader will look for a dict XYMAP_DATA or a list of
XYMAP_DATA_LIST (a list of XYMAP_DATA dicts). Each XYMAP_DATA dict should contain
`{"xymap": mapstring, "zcoord": mapname/zcoord, "legend": dict, "prototypes": dict}`.
Args:
module_path (module_path): A python-path to a module containing
map data as either `XYMAP_DATA` or `XYMAP_DATA_LIST` variables.
Returns:
list: List of zero, one or more xy-map data dicts loaded from the module.
"""
map_data_list = variable_from_module(module_path, "XYMAP_DATA_LIST")
if not map_data_list:
map_data_list = [variable_from_module(module_path, "XYMAP_DATA")]
# inject the python path in the map data
for mapdata in map_data_list:
if not mapdata:
self.log(f"Could not find or load map from {module_path}.")
return
mapdata['module_path'] = module_path
return map_data_list
def reload(self):
"""
Reload and rebuild the grid. This is done on a server reload.
"""
self.log("(Re)loading grid ...")
self.ndb.grid = {}
nmaps = 0
loaded_mapdata = {}
changed = []
mapdata = self.db.map_data
if not mapdata:
self.db.mapdata = mapdata = {}
# generate all Maps - this will also initialize their components
# and bake any pathfinding paths (or load from disk-cache)
for zcoord, old_mapdata in mapdata.items():
self.log(f"Loading map '{zcoord}'...")
# we reload the map from module
new_mapdata = loaded_mapdata.get(zcoord)
if not new_mapdata:
if 'module_path' in old_mapdata:
for mapdata in self.maps_from_module(old_mapdata['module_path']):
loaded_mapdata[mapdata['zcoord']] = mapdata
else:
# nowhere to reload from - use what we have
loaded_mapdata[zcoord] = old_mapdata
new_mapdata = loaded_mapdata.get(zcoord)
if new_mapdata != old_mapdata:
self.log(f" XYMap data for Z='{zcoord}' has changed.")
changed.append(zcoord)
xymap = XYMap(dict(new_mapdata), Z=zcoord, xyzgrid=self)
xymap.parse()
xymap.calculate_path_matrix()
self.ndb.grid[zcoord] = xymap
nmaps += 1
# re-store changed data
for zcoord in changed:
self.db.map_data[zcoord] = loaded_mapdata[zcoord]
# store
self.log(f"Loaded and linked {nmaps} map(s).")
self.ndb.loaded = True
def add_maps(self, *mapdatas):
"""
Add map or maps to the grid.
Args:
*mapdatas (dict): Each argument is a dict structure
`{"map": <mapstr>, "legend": <legenddict>, "name": <name>,
"prototypes": <dict-of-dicts>, "module_path": <str>}`. The `prototypes are
coordinate-specific overrides for nodes/links on the map, keyed with their
(X,Y) coordinate within that map. The `module_path` is injected automatically
by self.maps_from_module.
Raises:
RuntimeError: If mapdata is malformed.
"""
for mapdata in mapdatas:
zcoord = mapdata.get('zcoord')
if not zcoord:
raise RuntimeError("XYZGrid.add_map data must contain 'zcoord'.")
self.db.map_data[zcoord] = mapdata
def remove_map(self, *zcoords, remove_objects=True):
"""
Remove an XYmap from the grid.
Args:
*zoords (str): The zcoords/XYmaps to remove.
remove_objects (bool, optional): If the synced database objects (rooms/exits) should
be removed alongside this map.
"""
# from evennia import set_trace;set_trace()
for zcoord in zcoords:
if zcoord in self.db.map_data:
self.db.map_data.pop(zcoord)
if remove_objects:
# we can't batch-delete because we want to run the .delete
# method that also wipes exits and moves content to save locations
for xyzroom in XYZRoom.objects.filter_xyz(xyz=('*', '*', zcoord)):
xyzroom.delete()
self.reload()
def delete(self):
"""
Clear the entire grid, including database entities, then the grid too.
"""
mapdata = self.db.map_data
if mapdata:
self.remove_map(*(zcoord for zcoord in self.db.map_data), remove_objects=True)
super().delete()
def spawn(self, xyz=('*', '*', '*'), directions=None):
"""
Create/recreate/update the in-game grid based on the stored Maps or for a specific Map
or coordinate.
Args:
xyz (tuple, optional): An (X,Y,Z) coordinate, where Z is the name of the map. `'*'`
acts as a wildcard.
directions (list, optional): A list of cardinal directions ('n', 'ne' etc).
Spawn exits only the given direction. If unset, all needed directions are spawned.
Examples:
- `xyz=('*', '*', '*')` (default) - spawn/update all maps.
- `xyz=(1, 3, 'foo')` - sync a specific element of map 'foo' only.
- `xyz=('*', '*', 'foo') - sync all elements of map 'foo'
- `xyz=(1, 3, '*') - sync all (1,3) coordinates on all maps (rarely useful)
- `xyz=(1, 3, 'foo')`, `direction='ne'` - sync only the north-eastern exit
out of the specific node on map 'foo'.
"""
x, y, z = xyz
wildcard = '*'
if z == wildcard:
xymaps = self.grid
elif self.ndb.grid and z in self.ndb.grid:
xymaps = {z: self.grid[z]}
else:
raise RuntimeError(f"The 'z' coordinate/name '{z}' is not found on the grid.")
# first build all nodes/rooms
for zcoord, xymap in xymaps.items():
self.log(f"spawning/updating nodes for Z='{zcoord}' ...")
xymap.spawn_nodes(xy=(x, y))
# next build all links between nodes (including between maps)
for zcoord, xymap in xymaps.items():
self.log(f"spawning/updating links for Z='{zcoord}' ...")
xymap.spawn_links(xy=(x, y), directions=directions)
def get_xyzgrid(print_errors=True):
"""
Helper for getting the grid. This will create the XYZGrid global script if it didn't
previously exist.
Args:
print_errors (bool, optional): Print errors directly to console rather than to log.
"""
xyzgrid = XYZGrid.objects.all()
if not xyzgrid:
# create a new one
xyzgrid, err = XYZGrid.create("XYZGrid")
if err:
raise RuntimeError(err)
xyzgrid.reload()
return xyzgrid
elif len(xyzgrid) > 1:
("Warning: More than one XYZGrid instances were found. This is an error and "
"only the first one will be used. Delete the other one(s) manually.")
xyzgrid = xyzgrid[0]
try:
if not xyzgrid.ndb.loaded:
xyzgrid.reload()
except Exception as err:
raise
if print_errors:
print(err)
else:
xyzgrid.log(str(err))
return xyzgrid

View file

@ -0,0 +1,582 @@
"""
XYZ-aware rooms and exits.
These are intended to be used with the XYZgrid - which interprets the `Z` 'coordinate' as
different (named) 2D XY maps. But if not wanting to use the XYZgrid gridding, these can also be
used as stand-alone XYZ-coordinate-aware rooms.
"""
from django.db.models import Q
from evennia.objects.objects import DefaultRoom, DefaultExit
from evennia.objects.manager import ObjectManager
from evennia.utils.utils import make_iter
# name of all tag categories. Note that the Z-coordinate is
# the `map_name` of the XYZgrid
MAP_X_TAG_CATEGORY = "room_x_coordinate"
MAP_Y_TAG_CATEGORY = "room_y_coordinate"
MAP_Z_TAG_CATEGORY = "room_z_coordinate"
MAP_XDEST_TAG_CATEGORY = "exit_dest_x_coordinate"
MAP_YDEST_TAG_CATEGORY = "exit_dest_y_coordinate"
MAP_ZDEST_TAG_CATEGORY = "exit_dest_z_coordinate"
GET_XYZGRID = None
class XYZManager(ObjectManager):
"""
This is accessed as `.objects` on the coordinate-aware typeclasses (`XYZRoom`, `XYZExit`). It
has all the normal Object/Room manager methods (filter/get etc) but also special helpers for
efficiently querying the room in the database based on XY coordinates.
"""
def filter_xyz(self, xyz=('*', '*', '*'), **kwargs):
"""
Filter queryset based on XYZ position on the grid. The Z-position is the name of the XYMap
Set a coordinate to `'*'` to act as a wildcard (setting all coords to `*` will thus find
*all* XYZ rooms). This will also find children of XYZRooms on the given coordinates.
Kwargs:
xyz (tuple, optional): A coordinate tuple (X, Y, Z) where each element is either
an `int` or `str`. The character `'*'` acts as a wild card. Note that
the `Z`-coordinate is the name of the map (case-sensitive) in the XYZgrid contrib.
**kwargs: All other kwargs are passed on to the query.
Returns:
django.db.queryset.Queryset: A queryset that can be combined
with further filtering.
"""
x, y, z = xyz
wildcard = '*'
return (
self
.filter_family(**kwargs)
.filter(
Q() if x == wildcard
else Q(db_tags__db_key=str(x), db_tags__db_category=MAP_X_TAG_CATEGORY))
.filter(
Q() if y == wildcard
else Q(db_tags__db_key=str(y), db_tags__db_category=MAP_Y_TAG_CATEGORY))
.filter(
Q() if z == wildcard
else Q(db_tags__db_key=str(z), db_tags__db_category=MAP_Z_TAG_CATEGORY))
)
def get_xyz(self, xyz=(0, 0, 'map'), **kwargs):
"""
Always return a single matched entity directly. This accepts no `*`-wildcards.
This will also find children of XYZRooms on the given coordinates.
Kwargs:
xyz (tuple): A coordinate tuple of `int` or `str` (not `'*'`, no wildcards are
allowed in get). The `Z`-coordinate acts as the name (case-sensitive) of the map in
the XYZgrid contrib.
**kwargs: All other kwargs are passed on to the query.
Returns:
XYRoom: A single room instance found at the combination of x, y and z given.
Raises:
XYZRoom.DoesNotExist: If no matching query was found.
XYZRoom.MultipleObjectsReturned: If more than one match was found (which should not
possible with a unique combination of x,y,z).
"""
# filter by tags, then figure out of we got a single match or not
query = self.filter_xyz(xyz=xyz, **kwargs)
ncount = query.count()
if ncount == 1:
return query.first()
# error - mimic default get() behavior but with a little more info
x, y, z = xyz
inp = (f"Query: xyz=({x},{y},{z}), " +
",".join(f"{key}={val}" for key, val in kwargs.items()))
if ncount > 1:
raise self.model.MultipleObjectsReturned(inp)
else:
raise self.model.DoesNotExist(inp)
class XYZExitManager(XYZManager):
"""
Used by Exits.
Manager that also allows searching for destinations based on XY coordinates.
"""
def filter_xyz_exit(self, xyz=('*', '*', '*'),
xyz_destination=('*', '*', '*'), **kwargs):
"""
Used by exits (objects with a source and -destination property).
Find all exits out of a source or to a particular destination. This will also find
children of XYZExit on the given coords..
Kwargs:
xyz (tuple, optional): A coordinate (X, Y, Z) for the source location. Each
element is either an `int` or `str`. The character `'*'` is used as a wildcard -
so setting all coordinates to the wildcard will return *all* XYZExits.
the `Z`-coordinate is the name of the map (case-sensitive) in the XYZgrid contrib.
xyz_destination (tuple, optional): Same as `xyz` but for the destination of the
exit.
**kwargs: All other kwargs are passed on to the query.
Returns:
django.db.queryset.Queryset: A queryset that can be combined
with further filtering.
Notes:
Depending on what coordinates are set to `*`, this can be used to
e.g. find all exits in a room, or leading to a room or even to rooms
in a particular X/Y row/column.
In the XYZgrid, `z_source != z_destination` means a _transit_ between different maps.
"""
x, y, z = xyz
xdest, ydest, zdest = xyz_destination
wildcard = '*'
return (
self
.filter_family(**kwargs)
.filter(
Q() if x == wildcard
else Q(db_tags__db_key=str(x), db_tags__db_category=MAP_X_TAG_CATEGORY))
.filter(
Q() if y == wildcard
else Q(db_tags__db_key=str(y), db_tags__db_category=MAP_Y_TAG_CATEGORY))
.filter(
Q() if z == wildcard
else Q(db_tags__db_key=str(z), db_tags__db_category=MAP_Z_TAG_CATEGORY))
.filter(
Q() if xdest == wildcard
else Q(db_tags__db_key=str(xdest), db_tags__db_category=MAP_XDEST_TAG_CATEGORY))
.filter(
Q() if ydest == wildcard
else Q(db_tags__db_key=str(ydest), db_tags__db_category=MAP_YDEST_TAG_CATEGORY))
.filter(
Q() if zdest == wildcard
else Q(db_tags__db_key=str(zdest), db_tags__db_category=MAP_ZDEST_TAG_CATEGORY))
)
def get_xyz_exit(self, xyz=(0, 0, 'map'), xyz_destination=(0, 0, 'map'), **kwargs):
"""
Used by exits (objects with a source and -destination property). Get a single
exit. All source/destination coordinates (as well as the map's name) are required.
This will also find children of XYZExits on the given coords.
Kwargs:
xyz (tuple, optional): A coordinate (X, Y, Z) for the source location. Each
element is either an `int` or `str` (not `*`, no wildcards are allowed for get).
the `Z`-coordinate is the name of the map (case-sensitive) in the XYZgrid contrib.
xyz_destination_coord (tuple, optional): Same as the `xyz` but for the destination of
the exit.
**kwargs: All other kwargs are passed on to the query.
Returns:
XYZExit: A single exit instance found at the combination of x, y and xgiven.
Raises:
XYZExit.DoesNotExist: If no matching query was found.
XYZExit.MultipleObjectsReturned: If more than one match was found (which should not
be possible with a unique combination of x,y,x).
Notes:
All coordinates are required.
"""
x, y, z = xyz
xdest, ydest, zdest = xyz_destination
# mimic get_family
paths = [self.model.path] + [
"%s.%s" % (cls.__module__, cls.__name__) for cls in self._get_subclasses(self.model)
]
kwargs["db_typeclass_path__in"] = paths
try:
return (
self
.filter(db_tags__db_key=str(z), db_tags__db_category=MAP_Z_TAG_CATEGORY)
.filter(db_tags__db_key=str(x), db_tags__db_category=MAP_X_TAG_CATEGORY)
.filter(db_tags__db_key=str(y), db_tags__db_category=MAP_Y_TAG_CATEGORY)
.filter(db_tags__db_key=str(xdest), db_tags__db_category=MAP_XDEST_TAG_CATEGORY)
.filter(db_tags__db_key=str(ydest), db_tags__db_category=MAP_YDEST_TAG_CATEGORY)
.filter(db_tags__db_key=str(zdest), db_tags__db_category=MAP_ZDEST_TAG_CATEGORY)
.get(**kwargs)
)
except self.model.DoesNotExist:
inp = (f"xyz=({x},{y},{z}),xyz_destination=({xdest},{ydest},{zdest})," +
",".join(f"{key}={val}" for key, val in kwargs.items()))
raise self.model.DoesNotExist(f"{self.model.__name__} "
f"matching query {inp} does not exist.")
class XYZRoom(DefaultRoom):
"""
A game location aware of its XYZ-position.
Special properties:
map_display (bool): If the return_appearance of the room should
show the map or not.
map_mode (str): One of 'nodes' or 'scan'. See `return_apperance`
for examples of how they differ.
map_visual_range (int): How far on the map one can see. This is a
fixed value here, but could also be dynamic based on skills,
light etc.
map_character_symbol (str): The character symbol to use to show
the character position. Can contain color info. Default is
the @-character.
map_area_client (bool): If True, map area will always fill the entire
client width. If False, the map area's width will vary with the
width of the currently displayed location description.
map_fill_all (bool): I the map area should fill the client width or not.
map_separator_char (str): The char to use to separate the map area from
the room description.
"""
# makes the `room.objects.filter_xymap` available
objects = XYZManager()
# default settings for map visualization
map_display = True
map_mode = 'nodes' # or 'scan'
map_visual_range = 2
map_character_symbol = "|g@|n"
map_align = 'c'
map_target_path_style = "|y{display_symbol}|n"
map_fill_all = True
map_separator_char = "|x~|n"
def __str__(self):
return repr(self)
def __repr__(self):
x, y, z = self.xyz
return f"<XYZRoom '{self.db_key}', XYZ=({x},{y},{z})>"
@property
def xyz(self):
if not hasattr(self, "_xyz"):
x = self.tags.get(category=MAP_X_TAG_CATEGORY, return_list=False)
y = self.tags.get(category=MAP_Y_TAG_CATEGORY, return_list=False)
z = self.tags.get(category=MAP_Z_TAG_CATEGORY, return_list=False)
if x is None or y is None or z is None:
# don't cache unfinished coordinate (probably tags have not finished saving)
return tuple(int(coord) if coord is not None and coord.isdigit() else coord
for coord in (x, y, z))
# cache result, convert to correct types (tags are strings)
self._xyz = tuple(int(coord) if coord.isdigit() else coord for coord in (x, y, z))
return self._xyz
@property
def xyzgrid(self):
global GET_XYZGRID
if not GET_XYZGRID:
from evennia.contrib.xyzgrid.xyzgrid import get_xyzgrid as GET_XYZGRID
return GET_XYZGRID()
@property
def xymap(self):
if not hasattr(self, "_xymap"):
xyzgrid = self.xyzgrid
_, _, Z = self.xyz
self._xymap = xyzgrid.get_map(Z)
return self._xymap
@classmethod
def create(cls, key, account=None, xyz=(0, 0, 'map'), **kwargs):
"""
Creation method aware of XYZ coordinates.
Args:
key (str): New name of object to create.
account (Account, optional): Any Account to tie to this entity (usually not used for
rooms).
xyz (tuple, optional): A 3D coordinate (X, Y, Z) for this room's location on a
map grid. Each element can theoretically be either `int` or `str`, but for the
XYZgrid, the X, Y are always integers while the `Z` coordinate is used for the
map's name.
**kwargs: Will be passed into the normal `DefaultRoom.create` method.
Returns:
room (Object): A newly created Room of the given typeclass.
errors (list): A list of errors in string form, if any.
Notes:
The (X, Y, Z) coordinate must be unique across the game. If trying to create
a room at a coordinate that already exists, an error will be returned.
"""
try:
x, y, z = xyz
except ValueError:
return None, [f"XYRroom.create got `xyz={xyz}` - needs a valid (X,Y,Z) "
"coordinate of ints/strings."]
existing_query = cls.objects.filter_xyz(xyz=(x, y, z))
if existing_query.exists():
existing_room = existing_query.first()
return None, [f"XYRoom XYZ=({x},{y},{z}) already exists "
f"(existing room is named '{existing_room.db_key}')!"]
tags = (
(str(x), MAP_X_TAG_CATEGORY),
(str(y), MAP_Y_TAG_CATEGORY),
(str(z), MAP_Z_TAG_CATEGORY),
)
return DefaultRoom.create(key, account=account, tags=tags, typeclass=cls, **kwargs)
def get_display_name(self, looker, **kwargs):
"""
Shows both the #dbref and the xyz coord to staff.
Args:
looker (TypedObject): The object or account that is looking
at/getting inforamtion for this object.
Returns:
name (str): A string containing the name of the object,
including the DBREF and XYZ coord if this user is
privileged to control the room.
"""
if self.locks.check_lockstring(looker, "perm(Builder)"):
x, y, z = self.xyz
return f"{self.name}[#{self.id}({x},{y},{z})]"
return self.name
def return_appearance(self, looker, **kwargs):
"""
Displays the map in addition to the room description
Args:
looker (Object): The one looking.
Keyword Args:
map_display (bool): Turn on/off map display.
map_visual_range (int): How 'far' one can see on the map. For
'nodes' mode, this is how many connected nodes away, for
'scan' mode, this is number of characters away on the map.
Default is a visual range of 2 (nodes).
map_mode (str): One of 'node' (default) or 'scan'.
map_character_symbol (str): The character symbol to use. Defaults to '@'.
This can also be colored with standard color tags. Set to `None`
to just show the current node.
Examples:
Assume this is the full map (where '@' is the character location):
::
#----------------#
| |
| |
# @------------#-#
| |
#----------------#
This is how it will look in 'nodes' mode with `visual_range=2`:
::
@------------#-#
And in 'scan' mode with `visual_range=2`:
::
|
|
# @--
|
#----
Notes:
The map kwargs default to values with the same names set on the
XYZRoom class; these can be changed by overriding the room.
We return the map display as a separate msg() call here, in order
to make it easier to break this out into a client pane etc. The
map is tagged with type='xymap'.
"""
# normal get_appearance of a room
room_desc = super().return_appearance(looker, **kwargs)
# get current xymap
xyz = self.xyz
xymap = self.xyzgrid.get_map(xyz[2])
if xymap and kwargs.get('map_display', xymap.options.get("map_display", self.map_display)):
# show the near-area map.
map_character_symbol = kwargs.get(
'map_character_symbol',
xymap.options.get("map_character_symbol", self.map_character_symbol))
map_visual_range = kwargs.get(
"map_visual_range", xymap.options.get("map_visual_range", self.map_visual_range))
map_mode = kwargs.get(
"map_mode", xymap.options.get("map_mode", self.map_mode))
map_align = kwargs.get(
"map_align", xymap.options.get("map_align", self.map_align))
map_target_path_style = kwargs.get(
"map_target_path_style",
xymap.options.get("map_target_path_style", self.map_target_path_style))
map_area_client = kwargs.get(
"map_fill_all", xymap.options.get("map_fill_all", self.map_fill_all))
map_separator_char = kwargs.get(
"map_separator_char",
xymap.options.get("map_separator_char", self.map_separator_char))
client_width, _ = looker.sessions.get()[0].get_client_size()
map_width = xymap.max_x
if map_area_client:
display_width = client_width
else:
display_width = max(map_width,
max(len(line) for line in room_desc.split("\n")))
# align map
map_indent = 0
sep_width = display_width
if map_align == 'r':
map_indent = max(0, display_width - map_width)
elif map_align == 'c':
map_indent = max(0, (display_width - map_width) // 2)
# 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=map_visual_range,
mode=map_mode,
target=target_xy,
target_path_style=map_target_path_style,
character=map_character_symbol,
max_size=(display_width, None),
indent=map_indent
)
sep = map_separator_char * sep_width
map_display = f"{sep}|n\n{map_display}\n{sep}"
# echo directly to make easier to separate in client
looker.msg(text=(map_display, {"type": "xymap"}), options=None)
return room_desc
class XYZExit(DefaultExit):
"""
An exit that is aware of the XYZ coordinate system.
"""
objects = XYZExitManager()
def __str__(self):
return repr(self)
def __repr__(self):
x, y, z = self.xyz
xd, yd, zd = self.xyz_destination
return f"<XYZExit '{self.db_key}', XYZ=({x},{y},{z})->({xd},{yd},{zd})>"
@property
def xyzgrid(self):
global GET_XYZGRID
if not GET_XYZGRID:
from evennia.contrib.xyzgrid.xyzgrid import get_xyzgrid as GET_XYZGRID
return GET_XYZGRID()
@property
def xyz(self):
if not hasattr(self, "_xyz"):
x = self.tags.get(category=MAP_X_TAG_CATEGORY, return_list=False)
y = self.tags.get(category=MAP_Y_TAG_CATEGORY, return_list=False)
z = self.tags.get(category=MAP_Z_TAG_CATEGORY, return_list=False)
if x is None or y is None or z is None:
# don't cache yet unfinished coordinate
return (x, y, z)
# cache result
self._xyz = (x, y, z)
return self._xyz
@property
def xyz_destination(self):
if not hasattr(self, "_xyz_destination"):
xd = self.tags.get(category=MAP_XDEST_TAG_CATEGORY, return_list=False)
yd = self.tags.get(category=MAP_YDEST_TAG_CATEGORY, return_list=False)
zd = self.tags.get(category=MAP_ZDEST_TAG_CATEGORY, return_list=False)
if xd is None or yd is None or zd is None:
# don't cache unfinished coordinate
return (xd, yd, zd)
# cache result
self._xyz_destination = (xd, yd, zd)
return self._xyz_destination
@classmethod
def create(cls, key, account=None, xyz=(0, 0, 'map'), xyz_destination=(0, 0, 'map'),
location=None, destination=None, **kwargs):
"""
Creation method aware of coordinates.
Args:
key (str): New name of object to create.
account (Account, optional): Any Account to tie to this entity (unused for exits).
xyz (tuple or None, optional): A 3D coordinate (X, Y, Z) for this room's location
on a map grid. Each element can theoretically be either `int` or `str`, but for the
XYZgrid contrib, the X, Y are always integers while the `Z` coordinate is used for
the map's name. Set to `None` if instead using a direct room reference with
`location`.
xyz_destination (tuple, optional): The XYZ coordinate of the place the exit
leads to. Will be ignored if `destination` is given directly.
location (Object, optional): If given, overrides `xyz` coordinate. This can be used
to place this exit in any room, including non-XYRoom type rooms.
destination (Object, optional): If given, overrides `xyz_destination`. This can
be any room (including non-XYRooms) and is not checked for XYZ coordinates.
**kwargs: Will be passed into the normal `DefaultRoom.create` method.
Returns:
tuple: A tuple `(exit, errors)`, where the errors is a list containing all found
errors (in which case the returned exit will be `None`).
"""
tags = []
if location:
source = location
else:
try:
x, y, z = xyz
except ValueError:
return None, ["XYExit.create need either `xyz=(X,Y,Z)` coordinate or a `location`."]
else:
source = XYZRoom.objects.get_xyz(xyz=(x, y, z))
tags.extend(((str(x), MAP_X_TAG_CATEGORY),
(str(y), MAP_Y_TAG_CATEGORY),
(str(z), MAP_Z_TAG_CATEGORY)))
if destination:
dest = destination
else:
try:
xdest, ydest, zdest = xyz_destination
except ValueError:
return None, ["XYExit.create need either `xyz_destination=(X,Y,Z)` coordinate "
"or a `destination`."]
else:
dest = XYZRoom.objects.get_xyz(xyz=(xdest, ydest, zdest))
tags.extend(((str(xdest), MAP_XDEST_TAG_CATEGORY),
(str(ydest), MAP_YDEST_TAG_CATEGORY),
(str(zdest), MAP_ZDEST_TAG_CATEGORY)))
return DefaultExit.create(
key, source, dest,
account=account, tags=tags, typeclass=cls, **kwargs)