Start refactor contrib folder
This commit is contained in:
parent
7f0d314e7f
commit
f5f75bd04d
107 changed files with 34 additions and 2 deletions
3
evennia/contrib/grid/README.md
Normal file
3
evennia/contrib/grid/README.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Grid contribs
|
||||
|
||||
General contribs dealing with the game-world grid, maps and rooms.
|
||||
593
evennia/contrib/grid/extended_room.py
Normal file
593
evennia/contrib/grid/extended_room.py
Normal 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)
|
||||
502
evennia/contrib/grid/mapbuilder.py
Normal file
502
evennia/contrib/grid/mapbuilder.py
Normal 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)
|
||||
172
evennia/contrib/grid/simpledoor.py
Normal file
172
evennia/contrib/grid/simpledoor.py
Normal 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)
|
||||
144
evennia/contrib/grid/slow_exit.py
Normal file
144
evennia/contrib/grid/slow_exit.py
Normal 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.")
|
||||
777
evennia/contrib/grid/wilderness.py
Normal file
777
evennia/contrib/grid/wilderness.py
Normal 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
|
||||
66
evennia/contrib/grid/xyzgrid/README.md
Normal file
66
evennia/contrib/grid/xyzgrid/README.md
Normal 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.
|
||||
0
evennia/contrib/grid/xyzgrid/__init__.py
Normal file
0
evennia/contrib/grid/xyzgrid/__init__.py
Normal file
474
evennia/contrib/grid/xyzgrid/commands.py
Normal file
474
evennia/contrib/grid/xyzgrid/commands.py
Normal 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())
|
||||
291
evennia/contrib/grid/xyzgrid/example.py
Normal file
291
evennia/contrib/grid/xyzgrid/example.py
Normal 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
|
||||
]
|
||||
424
evennia/contrib/grid/xyzgrid/launchcmd.py
Normal file
424
evennia/contrib/grid/xyzgrid/launchcmd.py
Normal 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.")
|
||||
|
||||
47
evennia/contrib/grid/xyzgrid/prototypes.py
Normal file
47
evennia/contrib/grid/xyzgrid/prototypes.py
Normal 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]
|
||||
1297
evennia/contrib/grid/xyzgrid/tests.py
Normal file
1297
evennia/contrib/grid/xyzgrid/tests.py
Normal file
File diff suppressed because it is too large
Load diff
55
evennia/contrib/grid/xyzgrid/utils.py
Normal file
55
evennia/contrib/grid/xyzgrid/utils.py
Normal 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
|
||||
945
evennia/contrib/grid/xyzgrid/xymap.py
Normal file
945
evennia/contrib/grid/xyzgrid/xymap.py
Normal 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
|
||||
1295
evennia/contrib/grid/xyzgrid/xymap_legend.py
Normal file
1295
evennia/contrib/grid/xyzgrid/xymap_legend.py
Normal file
File diff suppressed because it is too large
Load diff
308
evennia/contrib/grid/xyzgrid/xyzgrid.py
Normal file
308
evennia/contrib/grid/xyzgrid/xyzgrid.py
Normal 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
|
||||
582
evennia/contrib/grid/xyzgrid/xyzroom.py
Normal file
582
evennia/contrib/grid/xyzgrid/xyzroom.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue