Refactor equipmenthandler

This commit is contained in:
Griatch 2022-04-09 23:51:48 +02:00
parent b2e41c2ddc
commit df5ae68a3c
4 changed files with 314 additions and 296 deletions

View file

@ -3,12 +3,12 @@ Base Character and NPCs.
"""
from evennia.objects.objects import DefaultCharacter, DefaultObject
from evennia.typeclasses.attributes import AttributeProperty
from evennia.utils.utils import lazy_property, int2str
from .objects import EvAdventureObject
from . import rules
from .enums import Ability, WieldLocation
class EquipmentError(TypeError):
@ -17,316 +17,226 @@ class EquipmentError(TypeError):
class EquipmentHandler:
"""
_Knave_ puts a lot of emphasis on the inventory. You have 20 inventory slots,
Some things, like torches can fit multiple in one slot, other (like
_Knave_ puts a lot of emphasis on the inventory. You have CON_DEFENSE inventory
slots. Some things, like torches can fit multiple in one slot, other (like
big weapons) use more than one slot. The items carried and wielded has a big impact
on character customization - even magic requires carrying a runestone per spell.
The inventory also doubles as a measure of negative effects. Getting soaked in mud
or slime could gunk up some of your inventory slots and make the items there unusuable
until you cleaned them.
until you clean them.
"""
# these are the equipment slots available
total_slots = 20
wield_slots = ["shield", "weapon"]
wear_slots = ["helmet", "armor"]
save_attribute = "inventory_slots"
def __init__(self, obj):
self.obj = obj
self._slots_used = None
self._wielded = None
self._worn = None
self._armor = None
self._load()
def _wield_or_wear(self, item, action="wear"):
def _load(self):
"""
Wield or wear a previously carried item in one of the supported wield/wear slots. Items need
to have the wieldable/wearable tag and will get a wielded/worn tag. The slot to occupy is
retrieved from the item itself.
Args:
item (Object): The object to wield. This will replace any existing
wieldable item in that spot.
action (str): One of 'wield' or 'wear'.
Returns:
tuple: (slot, old_item - the slot-name this item was
assigned to (like 'helmet') and any old item that was replaced in that location,.
(else `old_item` is `None`). This is useful for returning info messages
to the user.
Raises:
EquipmentError: If there is a problem wielding the item.
Notes:
Since the action of wielding is so similar to wearing, we use the same code for both,
just exchanging which slot to use and the wield/wear and wielded/worn texts.
Load or create a new slot storage.
"""
adjective = 'wearable' if action == 'wear' else 'wieldable'
verb = "worn" if action == 'wear' else 'wielded'
self.slots = self.obj.attributes.get(
self.save_attribute,
category="inventory",
default={
WieldLocation.WEAPON_HAND: None,
WieldLocation.SHIELD_HAND: None,
WieldLocation.TWO_HANDS: None,
WieldLocation.BODY: None,
WieldLocation.HEAD: None,
WieldLocation.BACKPACK: []
}
)
if item not in self.obj.contents:
raise EquipmentError(f"You need to pick it up before you can use it.")
if item in self.wielded:
raise EquipmentError(f"Already using {item.key}")
if not item.tags.has(adjective, category="inventory"):
# must have wieldable/wearable tag
raise EquipmentError(f"Cannot {action} {item.key}")
def _count_slots(self):
"""
Count slot usage. This is fetched from the .size Attribute of the
object. The size can also be partial slots.
# see if an existing item already sits in the relevant slot
if action == 'wear':
slot = item.wear_slot
old_item = self.worn.get(slot)
self.worn[slot] = item
else:
slot = item.wield_slot
old_item = self.wielded.get(slot)
self.wielded[item]
"""
slots = self.slots
wield_usage = sum(
getattr(slotobj, "size", 0) or 0
for slot, slotobj in slots.items()
if slot is not WieldLocation.BACKPACK
)
backpack_usage = sum(
getattr(slotobj, "size", 0) or 0
for slotobj in slots[WieldLocation.BACKPACK]
)
return wield_usage + backpack_usage
# untag old, tag the new and store it in .wielded dict for easy access
if old_item:
old_item.tags.remove(verb, category="inventory")
item.tags.add(verb, category="inventory")
def _save(self):
"""
Save slot to storage.
return slot, old_item
"""
self.obj.attributes.add(self.save_attribute, category="inventory")
@property
def slots_used(self):
def max_slots(self):
"""
Return how many slots are used up (out of .total_slots). Certain, big items may use more
than one slot. Also caches the results.
The max amount of equipment slots ('carrying capacity') is based on
the constitution defense.
"""
slots_used = self._slots_used
if slots_used is None:
slots_used = self._slots_used = sum(
item.inventory_slot_usage for item in self.contents
)
return slots_used
return getattr(self.obj, Ability.CON_DEFENSE.value, 11)
@property
def all(self):
def validate_slot_usage(self, obj):
"""
Get all carried items. Used by an 'inventory' command.
"""
return self.obj.contents
@property
def worn(self):
"""
Get (and cache) all worn items.
"""
worn = self._worn
if worn is None:
worn = self._worn = list(
DefaultObject.objects
.get_by_tag(["wearable", "worn"], category="inventory")
.filter(db_location=self.obj)
)
return worn
@property
def wielded(self):
wielded = self._wielded
if wielded is None:
wielded = self._wielded = list(
DefaultObject.objects
.get_by_tag(["wieldable", "wielded"], category="inventory")
.filter(db_location=self.obj)
)
return wielded
@property
def carried(self):
wielded_or_worn = self.wielded + self.worn
return [item for item in self.contents if item not in wielded_or_worn]
@property
def armor_defense(self):
"""
Figure out the total armor defense of the character. This is a combination
of armor from worn items (helmets, armor) and wielded ones (shields).
"""
armor = self._armor
if armor is None:
# recalculate and refresh cache. Default for unarmored enemy is armor defense of 11.
armor = self._armor = sum(item.armor for item in self.worn + self.wielded) or 11
return armor
def has_space(self, item):
"""
Check if there's room in equipment for this item.
Check if obj can fit in equipment, based on its size.
Args:
item (Object): An entity that takes up space.
obj (EvAdventureObject): The object to add.
Returns:
bool: If there's room or not.
Notes:
Also informs the user of the failure.
Raise:
EquipmentError: If there's not enough room.
"""
needed_slots = getattr(item, "inventory_slot_usage", 1)
free = self.slots_used - needed_slots
if free - needed_slots < 0:
self.obj.msg(f"No space in inventory - {item} takes up {needed_slots}, "
f"but $int2str({free}) $pluralize(is, {free}, are) available.")
return False
return True
def can_drop(self, item):
"""
Check if the item can be dropped - this is blocked by being worn or wielded.
Args:
item (Object): The item to drop.
Returns:
bool: If the object can be dropped.
Notes:
Informs the user of a failure.
"""
if item in self.wielded:
self.msg("You are currently wielding {item.key}. Unwield it first.")
return False
if item in self.worn:
self.msg("You are currently wearing {item.key}. Remove it first.")
return False
return True
def add(self, item):
"""
Add an item to the inventory. This will be called when picking something up. An item
must be carried before it can be worn or wielded.
There is a max number of carry-slots.
Args:
item (EvAdventureObject): The item to add (pick up).
Raises:
EquipmentError: If the item can't be added (usually because of lack of space).
"""
slots_needed = item.inventory_slot_usage
slots_already_used = self.slots_used
slots_free = self.total_slots - slots_already_used
if slot_needed > slots_free:
raise EquipmentError(
f"This requires {slots_needed} equipment slots - you have "
f"$int2str({slots_free}) $pluralize(slot, {slots_free}) available.")
# move to inventory
item.location = self.obj
self.slots_used += slots_needed
def remove(self, item):
"""
Remove (drop) an item from inventory. This will also un-wear or un-wield it.
Args:
item (EvAdventureObject): The item to drop.
Raises:
EquipmentError: If the item can't be dropped (usually because we don't have it).
"""
if item not in self.obj.contents:
raise EquipmentError("You are not carrying this item.")
self.slots_used -= item.inventory_slot_usage
def wear(self, item):
"""
Wear a previously carried item. The item itelf knows which slot it belongs in (like 'helmet'
or 'armor').
Args:
item (EvAdventureObject): The item to wear. Must already be carried.
Returns:
tuple: (slot, old_item - the slot-name this item was
assigned to (like 'helmet') and any old item that was replaced in that location
(else `old_item` is `None`). This is useful for returning info messages
to the user.
Raises:
EquipmentError: If there is a problem wearing the item.
"""
return self._wield_or_wear(item, action="wield")
def wield(self, item):
"""
Wield a previously carried item. The item itelf knows which wield-slot it belongs in (like
'helmet' or 'armor').
Args:
item (EvAdventureObject): The item to wield. Must already be carried.
Returns:
tuple: (slot, old_item - the wield-slot-name this item was
assigned to (like 'shield') and any old item that was replaced in that location
(else `old_item` is `None`). This is useful for returning info messages
to the user.
Raises:
EquipmentError: If there is a problem wielding the item.
"""
return self._wield_or_wear(item, action="wear")
class EvAdventureCharacter(DefaultCharacter):
"""
A Character for use with EvAdventure. This also works fine for
monsters and NPCS.
"""
# these are the ability bonuses. Defense is always 10 higher
strength = AttributeProperty(default=1)
dexterity = AttributeProperty(default=1)
constitution = AttributeProperty(default=1)
intelligence = AttributeProperty(default=1)
wisdom = AttributeProperty(default=1)
charisma = AttributeProperty(default=1)
armor = AttributeProperty(default=1)
exploration_speed = AttributeProperty(default=120)
combat_speed = AttributeProperty(default=40)
hp = AttributeProperty(default=4)
hp_max = AttributeProperty(default=4)
level = AttributeProperty(default=1)
xp = AttributeProperty(default=0)
morale = AttributeProperty(default=9) # only used for NPC/monster morale checks
@lazy_property
def equipment(self):
"""Allows to access equipment like char.equipment.worn"""
return EquipmentHandler(self)
@property
def weapon(self):
"""
Quick access to the character's currently wielded weapon.
Will return the "Unarmed" weapon if none other are found.
"""
# TODO
size = getattr(obj, "size", 0)
max_slots = self.max_slots
current_slot_usage = self._count_slots()
if current_slot_usage + size > max_slots:
slots_left = max_slots - current_slot_usage
raise EquipmentError(f"Equipment full ({int2str(slots_left)} slots "
f"remaining, {obj.key} needs {int2str(size)} "
f"$pluralize(slot, {size})).")
@property
def armor(self):
"""
Quick access to the character's current armor.
Will return the "Unarmored" armor if none other are found.
Armor provided by actually worn equipment/shield. For body armor
this is a base value, like 12, for shield/helmet, it's a bonus, like +1.
We treat values and bonuses equal and just add them up. This value
can thus be 0, the 'unarmored' default should be handled by the calling
method.
Returns:
int: Armor from equipment.
"""
# TODO
slots = self.slots
return sum((
getattr(slots[WieldLocation.BODY], "armor", 0),
getattr(slots[WieldLocation.SHIELD_HAND], "armor", 0),
getattr(slots[WieldLocation.HEAD], "armor", 0),
))
@property
def weapon(self):
"""
Conveniently get the currently active weapon.
Returns:
obj or None: The weapon. None if unarmored.
"""
# first checks two-handed wield, then one-handed; the two
# should never appear simultaneously anyhow (checked in `use` method).
slots = self.slots
weapon = slots[WieldLocation.TWO_HANDS]
if not weapon:
weapon = slots[WieldLocation.WEAPON_HAND]
return weapon
def use(self, obj):
"""
Make use of item - this makes use of the object's wield slot to decide where
it goes. If it doesn't have any, it goes into backpack.
Args:
obj (EvAdventureObject): Thing to use.
Raises:
EquipmentError: If there's no room in inventory. It will contains the details
of the error, suitable to echo to user.
Notes:
If using an item already in the backpack, it should first be `removed` from the
backpack, before applying here - otherwise, it will be added a second time!
this will cleanly move any 'colliding' items to the backpack to
make the use possible (such as moving sword + shield to backpack when wielding
a two-handed weapon). If wanting to warn the user about this, it needs to happen
before this call.
"""
# first check if we have room for this
self.validate_slot_usage(obj)
slots = self.slots
use_slot = getattr(obj, "inventory_use_slot", WieldLocation.BACKPACK)
if use_slot is WieldLocation.TWO_HANDS:
# two-handed weapons can't co-exist with weapon/shield-hand used items
slots[WieldLocation.WEAPON_HAND] = slots[WieldLocation.SHIELD_HAND] = None
slots[use_slot] = obj
elif use_slot in (WieldLocation.WEAPON_HAND, WieldLocation.SHIELD_HAND):
# can't keep a two-handed weapon if adding a one-handede weapon or shield
slots[WieldLocation.TWO_HANDS] = None
slots[use_slot] = obj
elif use_slot is WieldLocation.BACKPACK:
# backpack has multiple slots.
slots[use_slot].append(obj)
else:
# for others (body, head), just replace whatever's there
slots[use_slot] = obj
# store new state
self._save()
def store(self, obj):
"""
Put something in the backpack specifically (even if it could be wield/worn).
"""
# check if we have room
self.validate_slot_usage(obj)
self.slots[WieldLocation.BACKPACK].append(obj)
self._save()
def remove(self, obj_or_slot):
"""
Remove specific object or objects from a slot.
Args:
obj_or_slot (EvAdventureObject or WieldLocation): The specific object or
location to empty. If this is WieldLocation.BACKPACK, all items
in the backpack will be emptied and returned!
Returns:
list: A list of 0, 1 or more objects emptied from the inventory.
"""
slots = self.slots
ret = []
if isinstance(obj_or_slot, WieldLocation):
ret = slots[obj_or_slot]
slots[obj_or_slot] = [] if obj_or_slot is WieldLocation.BACKPACK else None
elif obj_or_slot in self.obj.contents:
# object is in inventory, find out which slot and empty it
for slot, objslot in slots:
if slot is WieldLocation.BACKPACK:
try:
ret = objslot.remove(obj_or_slot)
break
except ValueError:
pass
elif objslot is obj_or_slot:
ret = objslot
slots[slot] = None
break
if ret:
self._save()
return ret
class LivingMixin:
"""
Helpers shared between all living things.
"""
@property
def hurt_level(self):
@ -353,7 +263,7 @@ class EvAdventureCharacter(DefaultCharacter):
def heal(self, hp, healer=None):
"""
Heal the character by a certain amount of HP.
Heal by a certain amount of HP.
"""
damage = self.hp_max - self.hp
@ -365,6 +275,54 @@ class EvAdventureCharacter(DefaultCharacter):
else:
self.msg(f"|g{healer.key} heals you for {healed} health.|n")
class EvAdventureCharacter(LivingMixin, DefaultCharacter):
"""
A Character for use with EvAdventure. This also works fine for
monsters and NPCS.
"""
# these are the ability bonuses. Defense is always 10 higher
strength = AttributeProperty(default=1)
dexterity = AttributeProperty(default=1)
constitution = AttributeProperty(default=1)
intelligence = AttributeProperty(default=1)
wisdom = AttributeProperty(default=1)
charisma = AttributeProperty(default=1)
exploration_speed = AttributeProperty(default=120)
combat_speed = AttributeProperty(default=40)
hp = AttributeProperty(default=4)
hp_max = AttributeProperty(default=4)
level = AttributeProperty(default=1)
xp = AttributeProperty(default=0)
morale = AttributeProperty(default=9) # only used for NPC/monster morale checks
@lazy_property
def equipment(self):
"""Allows to access equipment like char.equipment.worn"""
return EquipmentHandler(self)
@property
def weapon(self):
"""
Quick access to the character's currently wielded weapon.
"""
self.equipment.weapon
@property
def armor(self):
"""
Quick access to the character's current armor.
Will return the "Unarmored" armor level (11) if none other are found.
"""
self.equipment.armor or 11
def at_pre_object_receive(self, moved_object, source_location, **kwargs):
"""
Hook called by Evennia before moving an object here. Return False to abort move.
@ -414,7 +372,6 @@ class EvAdventureCharacter(DefaultCharacter):
"""
self.equipment.remove(moved_object)
def at_damage(self, dmg, attacker=None):
"""
Called when receiving damage for whatever reason. This
@ -440,7 +397,7 @@ class EvAdventureCharacter(DefaultCharacter):
"""
class EvAdventureNPC(DefaultCharacter):
class EvAdventureNPC(LivingMixin, DefaultCharacter):
"""
This is the base class for all non-player entities, including monsters. These
generally don't advance in level but uses a simplified, abstract measure of how
@ -452,19 +409,15 @@ class EvAdventureNPC(DefaultCharacter):
Morale is set explicitly per-NPC, usually between 7 and 9.
Monsters don't use equipment in the way PCs do, instead their weapons and equipment
are baked into their HD (and/or dropped as loot when they go down). If you want monsters
or NPCs that can level and work the same as PCs, base them off the EvAdventureCharacter
class instead.
Monsters don't use equipment in the way PCs do, instead they have a fixed armor
value, and their Abilities are dynamically generated from the HD (hit_dice).
Unlike for a Character, we generate all the abilities dynamically based on HD.
If wanting monsters or NPCs that can level and work the same as PCs, base them off the
EvAdventureCharacter class instead.
"""
hit_dice = AttributeProperty(default=1)
# note: this is the armor bonus, 10 lower than the armor defence (what is usually
# referred to as ascending AC for many older D&D versions). So if AC is 14, this value
# should be 4.
armor = AttributeProperty(default=1)
armor = AttributeProperty(default=11)
morale = AttributeProperty(default=9)
hp = AttributeProperty(default=8)
@ -502,4 +455,3 @@ class EvAdventureNPC(DefaultCharacter):
"""
self.hp = self.hp_max

View file

@ -0,0 +1,60 @@
"""
Enums are constants representing different things in EvAdventure. The advantage
of using an Enum over, say, a string is that if you make a typo using an unknown
enum, Python will give you an error while a typo in a string may go through silently.
It's used as a direct reference:
from enums import Ability
if abi is Ability.STR:
# ...
To get the `value` of an enum (must always be hashable, useful for Attribute lookups), use
`Ability.STR.value` (which would return 'strength' in our case).
"""
from enum import Enum
class Ability(Enum):
"""
The six base abilities (defense is always bonus + 10)
"""
STR = "strength"
DEX = "dexterity"
CON = "constitution"
INT = "intelligence"
WIS = "wisdom"
CHA = "charisma"
STR_DEFENSE = "strength_defense"
DEX_DEFENSE = "dexterity_defense"
CON_DEFENSE = "constitution_defense"
INT_DEFENSE = "intelligence_defense"
WIS_DEFENSE = "wisdom_defense"
CHA_DEFENSE = "charisma_defense"
ARMOR = "armor"
HP = "hp"
EXPLORATION_SPEED = "exploration_speed"
COMBAT_SPEED = "combat_speed"
LEVEL = "level"
XP = "xp"
class WieldLocation(Enum):
"""
Wield (or wear) locations.
"""
# wield/wear location
BACKPACK = "backpack"
WEAPON_HAND = "weapon_hand"
SHIELD_HAND = "shield_hand"
TWO_HANDS = "two_handed_weapons"
BODY = "body" # armor
HEAD = "head" # helmets
# combat-related
OPTIMAL_DISTANCE = "optimal_distance"
SUBOPTIMAL_DISTANCE = "suboptimal_distance"

View file

@ -3,11 +3,16 @@ All items in the game inherit from a base object. The properties (what you can d
with an object, such as wear, wield, eat, drink, kill etc) are all controlled by
Tags.
"""
from evennia.objects.objects import DefaultObject
from evennia.typeclasses.attributes import AttributeProperty
from .enums import WieldLocation, Ability
class EvAdventureObject(DefaultObject):
"""
@ -15,12 +20,13 @@ class EvAdventureObject(DefaultObject):
"""
# inventory management
wield_slot = AttributeProperty(default=None)
wear_slot = AttributeProperty(default=None)
inventory_slot_usage = AttributeProperty(default=1)
inventory_use_slot = AttributeProperty(default=WieldLocation.BACKPACK)
# how many inventory slots it uses (can be a fraction)
size = AttributeProperty(default=1)
armor = AttributeProperty(default=0)
# when 0, item is destroyed and is unusable
quality = AttributeProperty(default=1)
value = AttributeProperty(default=0)
class EvAdventureObjectFiller(EvAdventureObject):
@ -43,10 +49,10 @@ class EvAdventureWeapon(EvAdventureObject):
Base weapon class for all EvAdventure weapons.
"""
wield_slot = AttributeProperty(default="weapon")
inventory_use_slot = AttributeProperty(WieldLocation.WEAPON_HAND)
attack_type = AttributeProperty(default="strength")
defense_type = AttributeProperty(default="armor")
attack_type = AttributeProperty(default=Ability.STR)
defense_type = AttributeProperty(default=Ability.ARMOR)
damage_roll = AttributeProperty(default="1d6")
# at which ranges this weapon can be used. If not listed, unable to use

View file

@ -2713,7 +2713,7 @@ def run_in_main_thread(function_or_method, *args, **kwargs):
return threads.blockingCallFromThread(reactor, function_or_method, *args, **kwargs)
_INT2STR_MAP_NOUN = {1: "one", 2: "two", 3: "three", 4: "four", 5: "five", 6: "six",
_INT2STR_MAP_NOUN = {0: "no", 1: "one", 2: "two", 3: "three", 4: "four", 5: "five", 6: "six",
7: "seven", 8: "eight", 9: "nine", 10: "ten", 11: "eleven", 12: "twelve"}
_INT2STR_MAP_ADJ = {1: "1st", 2: "2nd", 3: "3rd"} # rest is Xth.