This is part of a series of tutorials; the main page can be found here.
The tutorial uses libtcod version 1.6.0 and above.
Now that you can explore a large dungeon, Iâm sure you canât help but notice a few things missing. Where are all the swords, armor, enchanted boots and other assorted junk? Sure, we have some cool items, but they can only be used once. We canât really handle weapons and armor in the current system. How do we solve this?
First, we can add a new component to take care of the new functionality. An item with the Equipment component can be equipped or taken off, and while equipped will give the player some bonuses (more power, defense, etc). Sounds good!
Now we must plan ahead how this data will be stored in our game. Itâs time for a small detour into game architecture!
You see, the way you store your data can have a big impact on how easy it will be to handle and debug. There are two types. A brittle data structure can be easily put in an inconsistent state. A strong data structure cannot; it always makes sense, no matter how you change it. For example, you can keep a list of equipped items. To equip, you move an item to the âequippedâ list. There are several inconsistent states: what does it mean for an item to be on both lists? What does it mean to be on no list? (Python will just delete an object if itâs not referenced anywhere.) This is brittle.
A stronger data structure is to have a âis_equippedâ property on each item, and if itâs True the item is equipped. This is much harder to break, because in any case the item is either equipped or unequipped; there are no weird states.
We will use the same idea for bonuses, which youâll see later on. In a nutshell, try to store data in a way that allows a minimum of inconsistent states. Duplicated data or data that requires perfect coordination to make sense is usually a bad sign. This is more of an art than a science, though, and there is no absolute answer. So after the tutorial it will all be up to you!
Ok, itâs time to code that up. Weâll have an Equipment component that knows whether itâs equipped. It will also have an associated slot, like âhandâ for weapons or âheadâ for helmets. The slot will simply be indicated by a string.
class Equipment:
#an object that can be equipped, yielding bonuses. automatically adds the Item component.
def __init__(self, slot):
self.slot = slot
self.is_equipped = False
def toggle_equip(self): #toggle equip/dequip status
if self.is_equipped:
self.dequip()
else:
self.equip()
def equip(self):
#equip object and show a message about it
self.is_equipped = True
message('Equipped ' + self.owner.name + ' on ' + self.slot + '.', libtcod.light_green)
def dequip(self):
#dequip object and show a message about it
if not self.is_equipped: return
self.is_equipped = False
message('Dequipped ' + self.owner.name + ' from ' + self.slot + '.', libtcod.light_yellow)
Nothing fancy there! To allow objects to have this component, add equipment=None to the parameters of the Object classâs __init__
function, and the usual component initialization code:
self.equipment = equipment
if self.equipment: #let the Equipment component know who owns it
self.equipment.owner = self
At that point, we can also create an Item component automatically, because a piece of Equipment is always an Item (can be picked up and used).
#there must be an Item component for the Equipment component to work properly
self.item = Item()
self.item.owner = self
When the player goes to the inventory screen and tries to use a piece of equipment, it will be equipped or dequipped. So, in the use function of the Item class, add to the beginning:
#special case: if the object has the Equipment component, the "use" action is to equip/dequip
if self.owner.equipment:
self.owner.equipment.toggle_equip()
return
Thatâs the basic functionality! To test it quickly, we can let a sword appear in the dungeon, by adding a new item choice in place_objects:
elif choice == 'sword':
#create a sword
equipment_component = Equipment(slot='right hand')
item = Object(x, y, '/', 'sword', libtcod.sky, equipment=equipment_component)
And item_chances[âswordâ] = 25 after the other itemâs chances, at the top of that function.
Ready to test! Equipping the sword doesnât do much though. Youâll also notice you can equip 2 swords at once (how cool is that?). But 3 swords or more is a bit unrealistic, so weâll take care of that.
We donât want to let the player equip more than one item in the same slot. Fair enough! Letâs make a function to check if any item occupies a slot, and return it while weâre at it:
def get_equipped_in_slot(slot): #returns the equipment in a slot, or None if it's empty
for obj in inventory:
if obj.equipment and obj.equipment.slot == slot and obj.equipment.is_equipped:
return obj.equipment
return None
We can use it to prevent a second item in the same slot, or better yet: dequip the old item to make room for the new one. In the equip function:
#if the slot is already being used, dequip whatever is there first
old_equipment = get_equipped_in_slot(self.slot)
if old_equipment is not None:
old_equipment.dequip()
Another nice behavior is to automatically equip picked up items, if their slots are available. In the pick_up function, at the end:
#special case: automatically equip, if the corresponding equipment slot is unused
equipment = self.owner.equipment
if equipment and get_equipped_in_slot(equipment.slot) is None:
equipment.equip()
It is necessary, though, that dropped items be dequipped; simply add to the drop function:
#special case: if the object has the Equipment component, dequip it before dropping
if self.owner.equipment:
self.owner.equipment.dequip()
Finally, another bit of polishing. Weâd like to see in the inventory which items are equipped! So in inventory_menu, this information should be shown next to the item names. Replace the line options = [item.name for item in inventory] with:
options = []
for item in inventory:
text = item.name
#show additional information, in case it's equipped
if item.equipment and item.equipment.is_equipped:
text = text + ' (on ' + item.equipment.slot + ')'
options.append(text)
Thatâs it. You can check the equipmentâs state in the inventory screen, and it changes correctly as you pick up, drop, equip and dequip various items!
The last bit is to make equipment useful, by letting it change the playerâs stats when equipped. We can do this in different ways, but as I mentioned in the beginning, itâs better to avoid brittle data structures. For example, you could simply add the bonus value to a stat (say, attack power) when the item is equipped, and subtract it when dequipped. This is brittle because any tiny mistake will permanently change the playerâs stats!
A more reliable approach is to calculate on-the-fly the playerâs stats when they are needed, based on the original stat and any bonuses. This way thereâs no room for inconsistencies â the stat is truly based on whatever bonuses apply at the moment.
But how can we change a stored variable to a dynamic value? Wonât this mean we have to change all of the code that uses those stats? Not really, because of a neat Python feature! You can define a read-only property that is calculated on-the-fly very easily:
@property
def power(self):
return self.base_power + bonus
The bonus will be defined later. So now accessing player.power will call this function instead of getting the value of a power variable. We still need a variable to hold the playerâs power not counting any bonuses, though, and thatâs called base_power. This means that, in the Fighter classâs __init__
function, we donât initialize power directly, but rather base_power:
self.base_power = power
More generally, you can get the value of power normally, but you only change it through base_power. So, you must also make this change in check_level_up.
All thatâs left is to calculate the bonus! An Equipment component will remember whatâs its power bonus, by passing it as a new argument at initialization. I will also go ahead and define the bonuses for all the other stats:
def __init__(self, slot, power_bonus=0, defense_bonus=0, max_hp_bonus=0):
self.power_bonus = power_bonus
self.defense_bonus = defense_bonus
self.max_hp_bonus = max_hp_bonus
The power property can now just iterate through all equipped items, and sum up their bonuses to get the needed total:
bonus = sum(equipment.power_bonus for equipment in get_all_equipped(self.owner))
Finally, we need a helper function that returns the list of equipped items. For the player, we just go through the inventory. For monsters, we just return an empty list since they donât really have any. Feel free to change this if you want to let monsters equip items as well!
def get_all_equipped(obj): #returns a list of equipped items
if obj == player:
equipped_list = []
for item in inventory:
if item.equipment and item.equipment.is_equipped:
equipped_list.append(item.equipment)
return equipped_list
else:
return [] #other objects have no equipment
Thatâs it! Attack power is now a dynamic property. That wasnât so hard! Remember that you can change the bonus calculation easily, so if there are other modifiers, permanent spells and other conditions, itâs only a small change away.
For the sake of completeness, here are the properties for the other stats:
@property
def defense(self): #return actual defense, by summing up the bonuses from all equipped items
bonus = sum(equipment.defense_bonus for equipment in get_all_equipped(self.owner))
return self.base_defense + bonus
@property
def max_hp(self): #return actual max_hp, by summing up the bonuses from all equipped items
bonus = sum(equipment.max_hp_bonus for equipment in get_all_equipped(self.owner))
return self.base_max_hp + bonus
Donât forget to make the appropriate changes in check_level_up as well. Now we can define some items with hefty bonuses! In place_objects, I changed the sword to have power_bonus=3, and added a shield for good measure:
elif choice == 'shield':
#create a shield
equipment_component = Equipment(slot='left hand', defense_bonus=1)
item = Object(x, y, '[', 'shield', libtcod.darker_orange, equipment=equipment_component)
You can get really creative with equipment, of course. Iâll just modify the chances to make them appear at level 4 and level 8, respectively:
item_chances['sword'] = from_dungeon_level([[5, 4]])
item_chances['shield'] = from_dungeon_level([[15, 8]])
Now, since we donât want the player to enter the dungeon unprepared, you can give him or her some starting equipment at the end of new_game:
#initial equipment: a dagger
equipment_component = Equipment(slot='right hand', power_bonus=2)
obj = Object(0, 0, '-', 'dagger', libtcod.sky, equipment=equipment_component)
inventory.append(obj)
equipment_component.equip()
obj.always_visible = True
Not bad! I also decreased the playerâs starting power to 2; we donât want to be too generous. Itâs a dungeon of doom after all!
I showed you read-only properties, which are a breeze to use. If youâre wondering about writable properties, check out the Python docs on the subject.
We managed to create a neat bonus system, and itâs generic enough that you can add new stats and ways to change them very easily. Thereâs also equipment and slots that you can choose at will. Now you can create all sorts of useful plunder for the player to discover!
The whole code is available here.