Difference between revisions of "Complete Roguelike Tutorial, using python3+libtcod, part 13"
Line 2: | Line 2: | ||
This is part of a series of tutorials; the main page can be found [[Complete Roguelike Tutorial, using python3+libtcod|here]]. | This is part of a series of tutorials; the main page can be found [[Complete Roguelike Tutorial, using python3+libtcod|here]]. | ||
</center></td></tr></table></center> | </center></td></tr></table></center> | ||
__TOC__ | |||
<center><h1>'''Adventure gear'''</h1></center> | |||
== Simplifying == | |||
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! | |||
== Basic equipment == | |||
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. | |||
<div style="padding: 5px; border: solid 1px #C0C0C0; background-color: #F0F0F0"><syntaxhighlight lang="python">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)</syntaxhighlight></div> | |||
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: | |||
<div style="padding: 5px; border: solid 1px #C0C0C0; background-color: #F0F0F0"><syntaxhighlight lang="python"> self.equipment = equipment | |||
if self.equipment: #let the Equipment component know who owns it | |||
self.equipment.owner = self</syntaxhighlight></div> | |||
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). | |||
<div style="padding: 5px; border: solid 1px #C0C0C0; background-color: #F0F0F0"><syntaxhighlight lang="python"> #there must be an Item component for the Equipment component to work properly | |||
self.item = Item() | |||
self.item.owner = self</syntaxhighlight></div> | |||
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: | |||
<div style="padding: 5px; border: solid 1px #C0C0C0; background-color: #F0F0F0"><syntaxhighlight lang="python"> #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</syntaxhighlight></div> | |||
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'': | |||
<div style="padding: 5px; border: solid 1px #C0C0C0; background-color: #F0F0F0"><syntaxhighlight lang="python"> elif choice == 'sword': | |||
#create a sword | |||
equipment_component = Equipment(slot='right hand') | |||
item = Object(x, y, '/', 'sword', libtcod.sky, equipment=equipment_component)</syntaxhighlight></div> | |||
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. | |||
== Equipment polish == | |||
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: | |||
<div style="padding: 5px; border: solid 1px #C0C0C0; background-color: #F0F0F0"><syntaxhighlight lang="python">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</syntaxhighlight></div> | |||
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: | |||
<div style="padding: 5px; border: solid 1px #C0C0C0; background-color: #F0F0F0"><syntaxhighlight lang="python"> #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()</syntaxhighlight></div> | |||
Another nice behavior is to automatically equip picked up items, if their slots are available. In the ''pick_up'' function, at the end: | |||
<div style="padding: 5px; border: solid 1px #C0C0C0; background-color: #F0F0F0"><syntaxhighlight lang="python"> #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()</syntaxhighlight></div> | |||
It is necessary, though, that dropped items be dequipped; simply add to the ''drop'' function: | |||
<div style="padding: 5px; border: solid 1px #C0C0C0; background-color: #F0F0F0"><syntaxhighlight lang="python"> #special case: if the object has the Equipment component, dequip it before dropping | |||
if self.owner.equipment: | |||
self.owner.equipment.dequip()</syntaxhighlight></div> | |||
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: | |||
<div style="padding: 5px; border: solid 1px #C0C0C0; background-color: #F0F0F0"><syntaxhighlight lang="python"> 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)</syntaxhighlight></div> | |||
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! | |||
== Bonus round == | |||
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: | |||
<div style="padding: 5px; border: solid 1px #C0C0C0; background-color: #F0F0F0"><syntaxhighlight lang="python"> @property | |||
def power(self): | |||
return self.base_power + bonus</syntaxhighlight></div> | |||
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'': | |||
<div style="padding: 5px; border: solid 1px #C0C0C0; background-color: #F0F0F0"><syntaxhighlight lang="python"> self.base_power = power</syntaxhighlight></div> | |||
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: | |||
<div style="padding: 5px; border: solid 1px #C0C0C0; background-color: #F0F0F0"><syntaxhighlight lang="python"> 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</syntaxhighlight></div> | |||
The ''power'' property can now just iterate through all equipped items, and sum up their bonuses to get the needed total: | |||
<div style="padding: 5px; border: solid 1px #C0C0C0; background-color: #F0F0F0"><syntaxhighlight lang="python"> bonus = sum(equipment.power_bonus for equipment in get_all_equipped(self.owner))</syntaxhighlight></div> | |||
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! | |||
<div style="padding: 5px; border: solid 1px #C0C0C0; background-color: #F0F0F0"><syntaxhighlight lang="python">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</syntaxhighlight></div> | |||
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: | |||
<div style="padding: 5px; border: solid 1px #C0C0C0; background-color: #F0F0F0"><syntaxhighlight lang="python"> @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</syntaxhighlight></div> | |||
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: | |||
<div style="padding: 5px; border: solid 1px #C0C0C0; background-color: #F0F0F0"><syntaxhighlight lang="python"> 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)</syntaxhighlight></div> | |||
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: | |||
<div style="padding: 5px; border: solid 1px #C0C0C0; background-color: #F0F0F0"><syntaxhighlight lang="python"> item_chances['sword'] = from_dungeon_level([[5, 4]]) | |||
item_chances['shield'] = from_dungeon_level([[15, 8]])</syntaxhighlight></div> | |||
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'': | |||
<div style="padding: 5px; border: solid 1px #C0C0C0; background-color: #F0F0F0"><syntaxhighlight lang="python"> #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</syntaxhighlight></div> | |||
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 [http://docs.python.org/2/library/functions.html#property 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 [[Complete Roguelike Tutorial, using python3+libtcod, part 13 code|here]]. | |||
[[Category:Developing]] |
Latest revision as of 01:37, 7 May 2018
This is part of a series of tutorials; the main page can be found here. |
Adventure gear
Simplifying
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!
Basic equipment
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.
Equipment polish
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!
Bonus round
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.