From e7f0149bf8e083a5af9252164b2f15b9759cd43d Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Sun, 9 Apr 2017 23:09:11 -0700 Subject: [PATCH] Adds new 'clothing' contrib module Adds a new contrib module, providing a system for wearable clothing objects that are appended to one's description. From the module itself: Items of clothing can be used to cover other items, and many options are provided to define your own clothing types and their limits and behaviors. For example, to have undergarments automatically covered by outerwear, or to put a limit on the number of each type of item that can be worn. Characters can also specify the style of wear for their clothing - I.E. to wear a scarf 'tied into a tight knot around the neck' or 'draped loosely across the shoulders' - to add an easy avenue of customization. The system as-is is fairly freeform - you can cover any garment with almost any other, for example - but it can easily be made more restrictive, and can even be tied into a system for armor or other equipment. The unit tests have not been put in for this module yet, but I thought I would share it first to get people's thoughts! --- evennia/contrib/clothing.py | 646 ++++++++++++++++++++++++++++++++++++ 1 file changed, 646 insertions(+) create mode 100644 evennia/contrib/clothing.py diff --git a/evennia/contrib/clothing.py b/evennia/contrib/clothing.py new file mode 100644 index 0000000000..b239615905 --- /dev/null +++ b/evennia/contrib/clothing.py @@ -0,0 +1,646 @@ +""" +Clothing - Provides a typeclass and commands for wearable clothing, +which is appended to a character's description when worn. + +Evennia contribution - Tim Ashley Jenkins 2017 + +Items of clothing can be used to cover other items, and many options +are provided to define your own clothing types and their limits and +behaviors. For example, to have undergarments automatically covered +by outerwear, or to put a limit on the number of each type of item +that can be worn. Characters can also specify the style of wear for +their clothing - I.E. to wear a scarf 'tied into a tight knot around +the neck' or 'draped loosely across the shoulders' - to add an easy +avenue of customization. The system as-is is fairly freeform - you +can cover any garment with almost any other, for example - but it +can easily be made more restrictive, and can even be tied into a +system for armor or other equipment. + +To install, import this module and have your default character +inherit from ClothedCharacter in your game's characters.py file: + + from evennia.contrib.clothing import ClothedCharacter + + class Character(ClothedCharacter): + +And do the same with the ClothedCharacterCmdSet in your game'same +default_cmdsets.py: + + from evennia.contrib.clothing import ClothedCharacterCmdSet + + class CharacterCmdSet(default_cmds.CharacterCmdSet): + +From here, you can use the default builder commands to create clothes +with which to test the system: + + @create a pretty dress : evennia.contrib.clothing.Clothing + @set dress/clothing_type = 'body' +""" + +from evennia import DefaultObject +from evennia import DefaultCharacter +from evennia import default_cmds +from evennia.commands.default.muxcommand import MuxCommand +from evennia.utils import search +from evennia.utils import list_to_string +from evennia.utils import evtable + +# Options start here. +# Maximum character length of 'wear style' strings, or None for unlimited. +WEARSTYLE_MAXLENGTH = 50 +# The order in which clothing types appear on the description. Untyped clothing goes last. +CLOTHING_TYPE_ORDER = ['hat','jewelry','top','undershirt','gloves','body','bottom','underpants','socks','shoes','accessory'] +# The maximum number of each type of clothes that can be worn. Unlimited if untyped or not specified. +CLOTHING_TYPE_LIMIT = { + 'hat':1, + 'gloves':1, + 'socks':1, + 'shoes':1 + } +# The maximum number of clothing items that can be worn, or None for unlimited. +CLOTHING_OVERALL_LIMIT = 20 +# What types of clothes will automatically cover what other types of clothes when worn. +CLOTHING_TYPE_AUTOCOVER = { + 'top':['undershirt'], + 'bottom':['underpants'], + 'body':['undershirt','underpants'], + 'shoes':['socks'] + } +# Types of clothes that can't be used to cover other clothes. +CLOTHING_TYPE_CANT_COVER_WITH = ['jewelry'] + +class Clothing(DefaultObject): + + def wear(self, wearer, wearstyle, quiet=False): + """ + Sets clothes to 'worn' and optionally echoes to the room. + + Args: + wearer (obj): character object wearing this clothing object + wearstyle (True or str): string describing the style of wear or True for none + + Kwargs: + quiet (bool): If false, does not message the room + + Notes: + Optionally sets db.worn with a 'wearstyle' that appends a short passage to + the end of the name of the clothing to describe how it's worn that shows + up in the wearer's desc - I.E. 'around his neck' or 'tied loosely around + her waist'. If db.worn is set to 'True' then just the name will be shown. + """ + # Set clothing as worn + self.db.worn = wearstyle + # Auto-cover appropirate clothing types, as specified above + to_cover = [] + if self.db.clothing_type and self.db.clothing_type in CLOTHING_TYPE_AUTOCOVER: + for garment in get_worn_clothes(wearer): + if garment.db.clothing_type and garment.db.clothing_type in CLOTHING_TYPE_AUTOCOVER[self.db.clothing_type]: + to_cover.append(garment) + garment.db.covered_by = self + # Return if quiet + if quiet: + return + # Echo a message to the room + message = "%s puts on %s" % (wearer, self.name) + if not wearstyle == True: + message = "%s wears %s %s" % (wearer, self.name, wearstyle) + if to_cover: + message = message + ", covering %s" % list_to_string(to_cover) + wearer.location.msg_contents(message + ".") + + def remove(self, wearer, quiet=False): + """ + Removes worn clothes and optionally echoes to the room. + + Args: + wearer (obj): character object wearing this clothing object + + Kwargs: + quiet (bool): If false, does not message the room + """ + self.db.worn = False + remove_message = "%s removes %s." % (wearer, self.name) + uncovered_list = [] + + # Check to see if any other clothes are covered by this object. + for thing in wearer.contents: + # If anything is covered by + if thing.db.covered_by == self: + thing.db.covered_by = False + uncovered_list.append(thing.name) + if len(uncovered_list) > 0: + remove_message = "%s removes %s, revealing %s." % (wearer, self.name, list_to_string(uncovered_list)) + # Echo a message to the room + if not quiet: + wearer.location.msg_contents(remove_message) + + def at_get(self): + """ + Makes absolutely sure clothes aren't already set as 'worn' + when they're picked up, in case they've somehow had their + location changed without getting removed. + """ + self.db.worn = False + +class ClothedCharacter(DefaultCharacter): + """ + Character that displays worn clothing when looked at. You can also + just copy the return_appearance hook defined below to your own game's + character typeclass. + """ + def return_appearance(self, looker): + """ + This formats a description. It is the hook a 'look' command + should call. + + Args: + looker (Object): Object doing the looking. + + Notes: + The name of every clothing item carried and worn by the character + is appended to their description. If the clothing's db.worn value + is set to True, only the name is appended, but if the value is a + string, the string is appended to the end of the name, to allow + characters to specify how clothing is worn. + """ + if not looker: + return "" + # get description, build string + string = "|c%s|n\n" % self.get_display_name(looker) + desc = self.db.desc + worn_string_list = [] + clothes_list = get_worn_clothes(self, exclude_covered=True) + # Append worn, uncovered clothing to the description + for garment in clothes_list: + # If 'worn' is True, just append the name + if garment.db.worn == True: + worn_string_list.append(garment.name) + # Otherwise, append the name and the string value of 'worn' + elif thing.db.worn: + worn_string_list.append("%s %s" % (garment.name, garment.db.worn)) + if desc: + string += "%s" % desc + # Append worn clothes. + if worn_string_list: + string += "|/|/%s is wearing %s." % (self, list_to_string(worn_string_list)) + else: + string += "|/|/%s is not wearing anything." % self + return string + +""" +---------------------------------------------------------------------------- +HELPER FUNCTIONS START HERE +---------------------------------------------------------------------------- +""" + +def order_clothes_list(clothes_list): + """ + Orders a given clothes list by the order specified in CLOTHING_TYPE_ORDER. + + Args: + clothes_list (list): List of clothing items to put in order + + Returns: + ordered_clothes_list (list): The same list as passed, but re-ordered + according to the hierarchy of clothing types + specified in CLOTHING_TYPE_ORDER. + """ + ordered_clothes_list = clothes_list + # For each type of clothing that exists... + for current_type in reversed(CLOTHING_TYPE_ORDER): + # Check each item in the given clothes list. + for clothes in clothes_list: + # If the item has a clothing type... + if clothes.db.clothing_type: + item_type = clothes.db.clothing_type + # And the clothing type matches the current type... + if item_type == current_type: + # Move it to the front of the list! + ordered_clothes_list.remove(clothes) + ordered_clothes_list.insert(0, clothes) + return ordered_clothes_list + +def get_worn_clothes(character, exclude_covered=False): + """ + Get a list of clothes worn by a given character. + + Args: + character (obj): The character to get a list of worn clothes from. + + Kwargs: + exclude_covered (bool): If True, excludes clothes covered by other + clothing from the returned list. + + Returns: + ordered_clothes_list (list): A list of clothing items worn by the + given character, ordered according to + the CLOTHING_TYPE_ORDER option specified + in this module. + """ + clothes_list = [] + for thing in character.contents: + # If uncovered or not excluding covered items + if not thing.db.covered_by or exclude_covered == False: + # If 'worn' is True, add to the list + if thing.db.worn == True: + clothes_list.append(thing) + # Might as well put them in order here too. + ordered_clothes_list = order_clothes_list(clothes_list) + return ordered_clothes_list + +def clothing_type_count(clothes_list): + """ + Returns a dictionary of the number of each clothing type + in a given list of clothing objects. + + Args: + clothes_list (list): A list of clothing items from which + to count the number of clothing types + represented among them. + + Returns: + types_count (dict): A dictionary of clothing types represented + in the given list and the number of each + clothing type represented. + """ + types_count = {} + for garment in clothes_list: + if garment.db.clothing_type: + type = garment.db.clothing_type + if type not in types_count.keys(): + types_count[type] = 1 + else: + types_count[type] += 1 + return types_count + +def single_type_count(clothes_list, type): + """ + Returns an integer value of the number of a given type of clothing in a list. + + Args: + clothes_list (list): List of clothing objects to count from + type (str): Clothing type to count + + Returns: + type_count (int): Number of garments of the specified type in the given + list of clothing objects + """ + type_count = 0 + for garment in clothes_list: + if garment.db.clothing_type: + if garment.db.clothing_type == type: + type_count += 1 + return type_count + +""" +---------------------------------------------------------------------------- +COMMANDS START HERE +---------------------------------------------------------------------------- +""" + +class ClothedCharacterCmdSet(default_cmds.CharacterCmdSet): + """ + Command set for clothing, including new versions of 'give' and 'drop' + that take worn and covered clothing into account, as well as a new + version of 'inventory' that differentiates between carried and worn + items. + """ + key = "DefaultCharacter" + + def at_cmdset_creation(self): + """ + Populates the cmdset + """ + super(ClothedCharacterCmdSet, self).at_cmdset_creation() + # + # any commands you add below will overload the default ones. + # + self.add(CmdWear()) + self.add(CmdRemove()) + self.add(CmdCover()) + self.add(CmdUncover()) + self.add(CmdGive()) + self.add(CmdDrop()) + self.add(CmdInventory()) + +class CmdWear(MuxCommand): + """ + Puts on an item of clothing you are holding. + + Usage: + wear [wear style] + + Examples: + wear shirt + wear scarf wrapped loosely about the shoulders + + All the clothes you are wearing are appended to your description. + If you provide a 'wear style' after the command, the message you + provide will be displayed after the clothing's name. + """ + + key = "wear" + help_category = "clothing" + + def func(self): + """ + This performs the actual command. + """ + if not self.args: + self.caller.msg("Usage: wear [wear style]") + return + clothing = self.caller.search(self.arglist[0], candidates=self.caller.contents) + wearstyle = True + if not clothing: + return + if not clothing.is_typeclass("evennia.contrib.clothing.Clothing"): + self.caller.msg("That's not clothes!") + return + + # Enforce overall clothing limit. + if CLOTHING_OVERALL_LIMIT and len(get_worn_clothes(self.caller)) >= CLOTHING_OVERALL_LIMIT: + self.caller.msg("You can't wear any more clothes.") + return + + # Apply individual clothing type limits. + if clothing.db.clothing_type and not clothing.db.worn: + type_count = single_type_count(get_worn_clothes(self.caller), clothing.db.clothing_type) + if clothing.db.clothing_type in CLOTHING_TYPE_LIMIT.keys(): + if type_count >= CLOTHING_TYPE_LIMIT[clothing.db.clothing_type]: + self.caller.msg("You can't wear any more clothes of the type '%s'." % clothing.db.clothing_type) + return + + if clothing.db.worn and len(self.arglist) == 1: + self.caller.msg("You're already wearing %s!" % clothing.name) + return + if len(self.arglist) > 1: # If wearstyle arguments given + wearstyle_list = self.arglist # Split arguments into a list of words + del wearstyle_list[0] # Leave first argument (the clothing item) out of the wearstyle + wearstring = ' '.join(str(e) for e in wearstyle_list) # Join list of args back into one string + if WEARSTYLE_MAXLENGTH and len(wearstring) > WEARSTYLE_MAXLENGTH: # If length of wearstyle exceeds limit + self.caller.msg("Please keep your wear style message to less than %i characters." % WEARSTYLE_MAXLENGTH) + else: + wearstyle = wearstring + clothing.wear(self.caller, wearstyle) + +class CmdRemove(MuxCommand): + """ + Takes off an item of clothing. + + Usage: + remove + + Removes an item of clothing you are wearing. You can't remove + clothes that are covered up by something else - you must take + off the covering item first. + """ + + key = "remove" + help_category = "clothing" + + def func(self): + """ + This performs the actual command. + """ + clothing = self.caller.search(self.args, candidates=self.caller.contents) + if not clothing: + return + if not clothing.db.worn: + self.caller.msg("You're not wearing that!") + return + if clothing.db.covered_by: + self.caller.msg("You have to take off %s first." % clothing.db.covered_by.name) + return + clothing.remove(self.caller) + +class CmdCover(MuxCommand): + """ + Covers a worn item of clothing with another you're holding or wearing. + + Usage: + cover [with] + + When you cover a clothing item, it is hidden and no longer appears in + your description until it's uncovered or the item covering it is removed. + You can't remove an item of clothing if it's covered. + """ + + key = "cover" + help_category = "clothing" + + def func(self): + """ + This performs the actual command. + """ + + if len(self.arglist) < 2: + self.caller.msg("Usage: cover [with] ") + return + # Get rid of optional 'with' syntax + if self.arglist[1].lower() == "with" and len(self.arglist) > 2: + del self.arglist[1] + to_cover = self.caller.search(self.arglist[0], candidates=self.caller.contents) + cover_with = self.caller.search(self.arglist[1], candidates=self.caller.contents) + if not to_cover or not cover_with: + return + if not to_cover.is_typeclass("evennia.contrib.clothing.Clothing"): + self.caller.msg("%s isn't clothes!" % to_cover.name) + return + if not cover_with.is_typeclass("evennia.contrib.clothing.Clothing"): + self.caller.msg("%s isn't clothes!" % cover_with.name) + return + if cover_with.db.clothing_type: + if cover_with.db.clothing_type in CLOTHING_TYPE_CANT_COVER_WITH: + self.caller.msg("You can't cover anything with that!") + return + if not to_cover.db.worn: + self.caller.msg("You're not wearing %s!" % to_cover.name) + return + if to_cover == cover_with: + self.caller.msg("You can't cover an item with itself!") + return + if cover_with.db.covered_by: + self.caller.msg("%s is covered by something else!" % cover_with.name) + return + if to_cover.db.covered_by: + self.caller.msg("%s is already covered by %s." % (cover_with.name, to_cover.db.covered_by.name)) + return + if not cover_with.db.worn: + cover_with.wear(self.caller, True) #Put on the item to cover with if it's not on already + self.caller.location.msg_contents("%s covers %s with %s." % (self.caller, to_cover.name, cover_with.name)) + to_cover.db.covered_by = cover_with + +class CmdUncover(MuxCommand): + """ + Reveals a worn item of clothing that's currently covered up. + + Usage: + uncover + + When you uncover an item of clothing, you allow it to appear in your + description without having to take off the garment that's currently + covering it. You can't uncover an item of clothing if the item covering + it is also covered by something else. + """ + + key = "uncover" + help_category = "clothing" + + def func(self): + """ + This performs the actual command. + """ + + if not self.args: + self.caller.msg("Usage: uncover ") + return + + to_uncover = self.caller.search(self.args, candidates=self.caller.contents) + if not to_uncover: + return + if not to_uncover.db.worn: + self.caller.msg("You're not wearing %s!" % to_uncover.name) + return + if not to_uncover.db.covered_by: + self.caller.msg("%s isn't covered by anything!" % to_uncover.name) + return + covered_by = to_uncover.db.covered_by + if covered_by.db.covered_by: + self.caller.msg("%s is under too many layers to uncover." % (to_uncover.name)) + return + self.caller.location.msg_contents("%s uncovers %s." % (self.caller, to_uncover.name)) + to_uncover.db.covered_by = None + +class CmdDrop(MuxCommand): + """ + drop something + + Usage: + drop + + Lets you drop an object from your inventory into the + location you are currently in. + """ + + key = "drop" + locks = "cmd:all()" + arg_regex = r"\s|$" + + def func(self): + """Implement command""" + + caller = self.caller + if not self.args: + caller.msg("Drop what?") + return + + # Because the DROP command by definition looks for items + # in inventory, call the search function using location = caller + obj = caller.search(self.args, location=caller, + nofound_string="You aren't carrying %s." % self.args, + multimatch_string="You carry more than one %s:" % self.args) + if not obj: + return + + # This part is new! + # You can't drop clothing items that are covered. + if obj.db.covered_by: + caller.msg("You can't drop that because it's covered by %s." % obj.db.covered_by) + return + # Remove clothes if they're dropped. + if obj.db.worn: + obj.remove(caller, quiet=True) + + obj.move_to(caller.location, quiet=True) + caller.msg("You drop %s." % (obj.name,)) + caller.location.msg_contents("%s drops %s." % + (caller.name, obj.name), + exclude=caller) + # Call the object script's at_drop() method. + obj.at_drop(caller) + +class CmdGive(MuxCommand): + """ + give away something to someone + + Usage: + give = + + Gives an items from your inventory to another character, + placing it in their inventory. + """ + key = "give" + locks = "cmd:all()" + arg_regex = r"\s|$" + + def func(self): + """Implement give""" + + caller = self.caller + if not self.args or not self.rhs: + caller.msg("Usage: give = ") + return + to_give = caller.search(self.lhs, location=caller, + nofound_string="You aren't carrying %s." % self.lhs, + multimatch_string="You carry more than one %s:" % self.lhs) + target = caller.search(self.rhs) + if not (to_give and target): + return + if target == caller: + caller.msg("You keep %s to yourself." % to_give.key) + return + if not to_give.location == caller: + caller.msg("You are not holding %s." % to_give.key) + return + # This is new! Can't give away something that's worn. + if to_give.db.covered_by: + caller.msg("You can't give that away because it's covered by %s." % to_give.db.covered_by) + return + # Remove clothes if they're dropped. + if to_give.db.worn: + to_give.remove(caller) + obj.move_to(caller.location, quiet=True) + # give object + caller.msg("You give %s to %s." % (to_give.key, target.key)) + to_give.move_to(target, quiet=True) + target.msg("%s gives you %s." % (caller.key, to_give.key)) + # Call the object script's at_give() method. + to_give.at_give(caller, target) + +class CmdInventory(MuxCommand): + """ + view inventory + + Usage: + inventory + inv + + Shows your inventory. + """ + # Alternate version of the inventory command which separates + # worn and carried items. + + key = "inventory" + aliases = ["inv", "i"] + locks = "cmd:all()" + arg_regex = r"$" + + def func(self): + """check inventory""" + items = self.caller.contents + + carry_table = evtable.EvTable(border="header") + wear_table = evtable.EvTable(border="header") + for item in items: + if not item.db.worn: + carry_table.add_row("|C%s|n" % item.name, item.db.desc or "") + if carry_table.nrows == 0: + carry_table.add_row("|CNothing.|n", "") + string = "|wYou are carrying:\n%s" % carry_table + for item in items: + if item.db.worn: + wear_table.add_row("|C%s|n" % item.name, item.db.desc or "") + if wear_table.nrows == 0: + wear_table.add_row("|CNothing.|n", "") + string += "|/|wYou are wearing:\n%s" % wear_table + self.caller.msg(string)