So now we have a dungeon full of monsters and scrolls, but it’s still somewhat lacking in variety. Where are all the swords, shields, staves and so on?
We need equipment. It differs from the items we have in two ways: you can only equip it in limited slots and it enhances your abilities while it’s being worn. This is unlike our items which are one-use-only (though they don’t have to be!).
Basic equipment
Let’s create an Equipment
component. It will know whether it’s
equipped or not and it will also have a slot
such as "hand" for
weapons or "head" for helmets.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
/// An object that can be equipped, yielding bonuses.
struct Equipment {
slot: Slot,
equipped: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
enum Slot {
LeftHand,
RightHand,
Head,
}
We’re using enum for the equipment slots to stay safe from
typos and forgetting to handle a new case in a match . You can just
use a string instead, though.
|
We’ll add this new component to our Object
and its new
method:
struct Object {
...
equipment: Option<Equipment>,
}
impl Object {
pub fn new(x: i32, y: i32, char: char, name: &str, color: Color, blocks: bool) -> Self {
Object {
...
equipment: None,
}
}
...
}
And let’s add a couple of new Object
methods for putting the
equipment on and off:
/// Equip object and show a message about it
pub fn equip(&mut self, messages: &mut Messages) {
if self.item.is_none() {
messages.add(
format!("Can't equip {:?} because it's not an Item.", self),
RED,
);
return;
};
if let Some(ref mut equipment) = self.equipment {
if !equipment.equipped {
equipment.equipped = true;
messages.add(
format!("Equipped {} on {}.", self.name, equipment.slot),
LIGHT_GREEN,
);
}
} else {
messages.add(
format!("Can't equip {:?} because it's not an Equipment.", self),
RED,
);
}
}
The meat of the method is in its middle: we take the equipment
component, mark its equipped
value as true and print a message. The
checks before and after are there to make sure that the Object
we’re
calling is indeed an Equipment
and also an Item
.
We could just ignore that since all equipments should be items (i.e. they can be carried in the inventory and dropped on the floor), it’s better to see when the unexpected happens.
Dequip works similarly:
/// Dequip object and show a message about it
pub fn dequip(&mut self, messages: &mut Messages) {
if self.item.is_none() {
messages.add(
format!("Can't dequip {:?} because it's not an Item.", self),
RED,
);
return;
};
if let Some(ref mut equipment) = self.equipment {
if equipment.equipped {
equipment.equipped = false;
messages.add(
format!("Dequipped {} from {}.", self.name, equipment.slot),
LIGHT_YELLOW,
);
}
} else {
messages.add(
format!("Can't dequip {:?} because it's not an Equipment.", self),
RED,
);
}
}
You may have noticed that we’re passing the message log
(Vec<(String, Color)> ) directly instead of the full Game struct
like in other Object methods such as attack or take_damage . This
is to avoid a double mutable borrow later. Try passing game: &mut
Game here instead and you’ll see the problem when we get to
toggle_equip .
|
How do we equip our items? We can rely on the existing Item
mechanism — when you try to "use" or "cast" an equipment (which you
can do since it’s an Item
, too), we’ll equip or unequip it.
First, let’s add a new Item type:
enum Item {
Heal,
Lightning,
Confuse,
Fireball,
Equipment,
}
We’ll just group all equipments under a single item type. We can always split them out later. |
Then in the callback match in use_item
:
let on_use = match item {
Heal => cast_heal,
Lightning => cast_lightning,
Fireball => cast_fireball,
Confuse => cast_confuse,
Equipment => toggle_equipment,
}
We have to create the toggle_equipment
function:
fn toggle_equipment(
inventory_id: usize,
_tcod: &mut Tcod,
game: &mut Game,
_objects: &mut [Object],
) -> UseResult {
let equipment = match game.inventory[inventory_id].equipment {
Some(equipment) => equipment,
None => return UseResult::Cancelled,
};
if equipment.equipped {
game.inventory[inventory_id].dequip(&mut game.messages);
} else {
game.inventory[inventory_id].equip(&mut game.messages);
}
UseResult::UsedAndKept
}
We’re returning a new UseResult
value here: one that says we have
used the item (so it’s the monsters' turn now), but we don’t want the
item to disappear!
Here is why we have to pass &mut game.messages to equip instead
of the full &mut game : in the same statement we look up the
equipment Object in game.inventory , which will make game mutably
borrowed for the duration of the equip call. So we can’t borrow it
second time. However, since we’re only borrowing game.inventory , we
can borrow game.messages separately! If you don’t like this, you could
turn the equip and dequip methods into standalone functions that
would take inventory_id and &mut game .
|
We need to add the new value to the UseResult
enum and handle the
new case in use_item
(as always, the compiler will complain so you
can rely on it to tell you where to look):
enum UseResult {
UsedUp,
UsedAndKept,
Cancelled,
}
match on_use(inventory_id, objects, game, tcod) {
UseResult::UsedUp => {
...
}
UseResult::UsedAndKept => {} // do nothing
UseResult::Cancelled => {
...
}
}
And hey! Now we can have regular items that don’t disappear upon use — such as wands, spellbooks, lockpicks, etc.
Finally, we need to update the item chances for this new
Equipment
type and add one to the game!
Add this to the item_chances
in place_objects
:
Weighted {weight: 1000, item: Item::Equipment},
And then this sword later on where we generate the items:
Item::Equipment => {
// create a sword
let mut object = Object::new(x, y, '/', "sword", SKY, false);
object.item = Some(Item::Equipment);
object.equipment = Some(Equipment{equipped: false, slot: Slot::RightHand});
object
}
As you can see, the weighted chances really don’t have to be
percentages. By setting the sword’s value to 1000
, it’s much more
likely to appear than any other item so we can find it early in the game
and test it!
We will set it back to something more reasonable later on.
Equipment polish
Now that we have the equipment basics in place, let’s finish it up. First, we only want to have one item equipped in any given slot. Here’s a function that returns an equipment that occupies a given slot (if it exists):
fn get_equipped_in_slot(slot: Slot, inventory: &[Object]) -> Option<usize> {
for (inventory_id, item) in inventory.iter().enumerate() {
if item
.equipment
.as_ref()
.map_or(false, |e| e.equipped && e.slot == slot)
{
return Some(inventory_id);
}
}
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
toggle_equipment
:
// if the slot is already being used, dequip whatever is there first
if let Some(current) = get_equipped_in_slot(equipment.slot, &game.inventory) {
game.inventory[current].dequip(&mut game.messages);
}
Another nice behavior is to automatically equip picked up items, if
their slots are available. In the pick_item_up
function, in the
else
branch:
let item = objects.swap_remove(object_id);
game.messages
.add(format!("You picked up a {}!", item.name), GREEN);
let index = game.inventory.len();
let slot = item.equipment.map(|e| e.slot);
game.inventory.push(item);
// automatically equip, if the corresponding equipment slot is unused
if let Some(slot) = slot {
if get_equipped_in_slot(slot, &game.inventory).is_none() {
game.inventory[index].equip(&mut game.messages);
}
}
We take the inventory index of the picked up item and an Option
of
the equipment slot (it’s None
if the item is not an equipment).
Then we check whether that slot is occupied, and if not, equip the new item.
We also need to de-equip an item if we’re dropping it. In drop_item
right after the game.inventory.remove
line:
if item.equipment.is_some() {
item.dequip(&mut game.messages);
}
It would also be nice if we could show which items are equipped in the
inventory screen. Replace the inventory.iter().map(…)
line in
inventory_menu
with:
inventory
.iter()
.map(|item| {
// show additional information, in case it's equipped
match item.equipment {
Some(equipment) if equipment.equipped => {
format!("{} (on {})", item.name, equipment.slot)
}
_ => item.name.clone(),
}
})
.collect()
We just replace the closure passed to map
to report the equipped
slot if available and the item name otherwise.
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!
One last thing to do here: the message log shows the equipment slot as capitalised:
Equipped sword on RightHand.
This is because the slots are enums and this is their Debug
representation — if they didn’t have #[derive(Debug)]
, we wouldn’t
be able to print them at all.
It would be nice if we could override the output somehow. Or better yet, leave the debug output as is but provide a human-readable alternative!
The way to provide a user facing output in Rust is to implement the Display trait.
Let’s give it a go:
impl std::fmt::Display for Slot {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match *self {
Slot::LeftHand => write!(f, "left hand"),
Slot::RightHand => write!(f, "right hand"),
Slot::Head => write!(f, "head"),
}
}
}
The write!
macro is similar to format!
or println!
but it writes
to a std::fmt::Formatter
struct.
We can now use this new formatting by replacing {:?}
to {}
every
time we print out a Slot
. So in inventory_menu
:
format!("{} (on {})", item.name, equipment.slot)
and then the "Equipped on" and "Dequipped on" messages in equip
and dequip
.
And now the equipment-related messages look much nicer!
Bonus round
The last bit is to make equipment useful, by letting it change the player’s stats when equipped. We 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.
Other languages have different ways of dealing with this (e.g. Python’s properties), but in Rust we have to rely on functions and methods.
We’ll implement the power
(attack) bonuses first and then do the
analogous work for defense
and HP
.
We’ll create a power
method on Object
which will return the total
power of the object (player or a monster):
pub fn power(&self, game: &Game) -> i32 {
let base_power = ...;
let bonus = ...;
base_power + bonus
}
So we get the base power of the object, then all the bonuses that apply and add them together. Easy!
The base power is stored in the Fighter
component, so we look it up
there and return 0
if the object doesn’t have the component
(alternatively, you may return an error or an Option<i32>
):
let base_power = self.fighter.map_or(0, |f| f.power);
The bonus is going to be a little more complicated: we want to go through all the object’s equipment and sum up their bonuses:
let bonus = self
.get_all_equipped(game)
.iter()
.map(|e| e.power_bonus)
.sum();
This is a bit of a handful, so let us unpack it.
First, we’ll have a method called get_all_equipped
which returns all
equipment for the given object. Then we go through each equipment (using
iter
) and sum up all their power bonuses.
The sum
iterator method which just adds all
numbers from an iterator together.
Finally, the Equipment
component needs to have the power_bonus
property!
So let’s start there, then implement get_all_equipped
and finally
switch to using the power
method.
Extending the equipment is the easy part:
struct Equipment {
slot: Slot,
equipped: bool,
power_bonus: i32,
}
By using i32
, the bonuses can be negative, e.g. for cursed items.
When we try to compile it, Rust will remind us that we need to set the
power bonus for our sword in place_objects
. Let’s just use 0
for now:
object.equipment = Some(Equipment{equipped: false, slot: Slot::RightHand, power_bonus: 0});
Next, we’ll add the get_all_equipped
method:
/// returns a list of equipped items
pub fn get_all_equipped(&self, game: &Game) -> Vec<Equipment> {
if self.name == "player" {
game.inventory
.iter()
.filter(|item| item.equipment.map_or(false, |e| e.equipped))
.map(|item| item.equipment.unwrap())
.collect()
} else {
vec![] // other objects have no equipment
}
}
We go through the inventory filter out anything that’s not an equipment and then return a vector of equipments.
The if self.name == "player" bit is a bit hacky. We have to do
it because player is the only object with an inventory. If we added
inventory to every object, or kept a unique ID associated with each
object, we wouldn’t need to do this.
|
Anyway, we can calculate the full power value of each object, but
there’s one more thing we ought to do. Remember that we now have a
power
property as well as a power
method. We should give them
distinct names so we know which is which when editing code. Let’s
change Fighter.power
to Fighter.base_power
:
struct Fighter {
// ...
base_power: i32,
// ...
}
And let’s update our power
method to use base_power
:
pub fn power(&self, game: &Game) -> i32 {
let base_power = self.fighter.map_or(0, |f| f.base_power);
let bonus: i32 = self
.get_all_equipped(game)
.iter()
.map(|e| e.power_bonus)
.sum();
base_power + bonus
}
When we try to compile this, we’ll see all the uses of Fighter.power
in our code! We can then go one by one and decide whether we need the
full or base power there.
First, we’ll update the damage calculation in our attack
method:
let damage = self.power(game) - target.fighter.map_or(0, |f| f.defense);
Next, we have to change power
to base_power
in place_objects
:
orc.fighter = Some(Fighter {
max_hp: 20,
hp: 20,
defense: 0,
base_power: 4, (1)
xp: 35,
on_death: DeathCallback::Monster,
});
...
troll.fighter = Some(Fighter {
max_hp: 30,
hp: 30,
defense: 2,
base_power: 8, (2)
xp: 100,
on_death: DeathCallback::Monster,
});
1 | power → base_power |
2 | power → base_power |
We want to show the full power in the character screen, so
handle_keys
match arm for c
will become:
let msg = format!("Character information
Level: {}
Experience: {}
Experience to level up: {}
Maximum HP: {}
Attack: {}
Defense: {}",
level,
fighter.xp,
level_up_xp,
fighter.max_hp,
player.power(game), (1)
fighter.defense,
);
1 | fighter.power → player.power(game) |
But the level_up
screen should only show the base power:
choice = menu(
"Level up! Choose a stat to raise:\n",
&[
format!("Constitution (+20 HP, from {})", fighter.max_hp),
format!("Strength (+1 attack, from {})", fighter.base_power), (1)
format!("Agility (+1 defense, from {})", fighter.defense),
],
LEVEL_SCREEN_WIDTH,
&mut tcod.root,
);
1 | fighter.power → fighter.base_power |
And do the same a bit later on when we actually level up power:
1 => {
fighter.base_power += 1; (1)
}
1 | fighter.power → fighter.base_power |
And finally, we need to player’s Fighter
component in new_game
:
player.fighter = Some(Fighter {
max_hp: 100,
hp: 100,
defense: 1,
base_power: 4, (1)
xp: 0,
on_death: DeathCallback::Player,
});
1 | fighter.power → fighter.base_power |
Doing defense
is exactly analogous: just rename defense
to
base_defense
in Fighter
, add defense_bonus
to Equipment
and
fix the compilation errors.
pub fn defense(&self, game: &Game) -> i32 {
let base_defense = self.fighter.map_or(0, |f| f.base_defense);
let bonus: i32 = self
.get_all_equipped(game)
.iter()
.map(|e| e.defense_bonus)
.sum();
base_defense + bonus
}
For example, here’s the final damage formula in attack
:
// a simple formula for attack damage
let damage = self.power(game) - target.defense(game);
The case for max_hp
is a little complicated by the fact that we use
it in more places (heal
and cast_heal
). The beginning is the same,
though: rename max_hp
in Fighter
to base_max_hp
, add
bonus_max_hp
to Equipment
and update monsters and equipment in
place_objects
.
So the final Fighter
struct looks like this:
struct Fighter {
hp: i32,
base_max_hp: i32,
base_defense: i32,
base_power: i32,
xp: i32,
on_death: DeathCallback,
}
The Equipment
struct:
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
/// An object that can be equipped, yielding bonuses.
struct Equipment {
slot: Slot,
equipped: bool,
max_hp_bonus: i32,
defense_bonus: i32,
power_bonus: i32,
}
And the and max_hp
method on Object
:
pub fn max_hp(&self, game: &Game) -> i32 {
let base_max_hp = self.fighter.map_or(0, |f| f.base_max_hp);
let bonus: i32 = self
.get_all_equipped(game)
.iter()
.map(|e| e.max_hp_bonus)
.sum();
base_max_hp + bonus
}
We’ll have to modify the heal
method to pass in Game
:
/// heal by the given amount, without going over the maximum
pub fn heal(&mut self, amount: i32, game: &Game) { (1)
let max_hp = self.max_hp(game); (2)
if let Some(ref mut fighter) = self.fighter {
fighter.hp += amount;
if fighter.hp > max_hp { (3)
fighter.hp = max_hp; (4)
}
}
}
1 | Pass &Game because it’s required by the max_hp method |
2 | Get the maximum HP count including bonuses |
3 | Use the max_hp variable here |
4 | And here |
And we need to fix cast_heal
as well:
fn cast_heal(
_inventory_id: usize,
_tcod: &mut Tcod,
game: &mut Game,
objects: &mut [Object],
) -> UseResult {
// heal the player
let player = &mut objects[PLAYER];
if let Some(fighter) = player.fighter {
if fighter.hp == player.max_hp(game) { (1)
game.messages.add("You are already at full health.", RED);
return UseResult::Cancelled;
}
game.messages
.add("Your wounds start to feel better!", LIGHT_VIOLET);
player.heal(HEAL_AMOUNT, game); (2)
return UseResult::UsedUp;
}
UseResult::Cancelled
}
1 | check HP against the max_hp method |
2 | heal requires &Game now |
And the healing at the beginning of next_level
is now:
let heal_hp = objects[PLAYER].max_hp(game) / 2;
objects[PLAYER].heal(heal_hp, game);
The player’s stats should use the defense
and max_hp
methods too:
let msg = format!("Character information
Level: {}
Experience: {}
Experience to level up: {}
Maximum HP: {}
Attack: {}
Defense: {}",
level,
fighter.xp,
level_up_xp,
player.max_hp(game),
player.power(game),
player.defense(game)
);
When we render the HP bar render_all
, we also need to update the hit point calculation:
// show the player's stats
let hp = objects[PLAYER].fighter.map_or(0, |f| f.hp);
let max_hp = objects[PLAYER].max_hp(game);
And same in level_up
:
let fighter = player.fighter.as_mut().unwrap();
let mut choice = None;
while choice.is_none() {
// keep asking until a choice is made
choice = menu(
"Level up! Choose a stat to raise:\n",
&[
format!("Constitution (+20 HP, from {})", fighter.base_max_hp), (1)
format!("Strength (+1 attack, from {})", fighter.base_power), (2)
format!("Agility (+1 defense, from {})", fighter.base_defense), (3)
],
LEVEL_SCREEN_WIDTH,
&mut tcod.root,
);
}
fighter.xp -= level_up_xp;
match choice.unwrap() {
0 => {
fighter.base_max_hp += 20; (4)
fighter.hp += 20;
}
1 => {
fighter.base_power += 1; (5)
}
2 => {
fighter.base_defense += 1; (6)
}
_ => unreachable!(),
}
1 | max_hp → base_max_hp |
2 | power → base_power |
3 | defense → base_defense |
4 | max_hp → base_max_hp |
5 | power → base_power |
6 | defense → base_defense |
And finally, the orc and troll monsters need to use base_max_hp
now:
// create an orc
let mut orc = Object::new(x, y, 'o', "orc", DESATURATED_GREEN, true);
orc.fighter = Some(Fighter {
base_max_hp: 20,
hp: 20,
base_defense: 0,
base_power: 4,
xp: 35,
on_death: DeathCallback::Monster,
});
orc.ai = Some(Ai::Basic);
orc
...
// create a troll
let mut troll = Object::new(x, y, 'T', "troll", DARKER_GREEN, true);
troll.fighter = Some(Fighter {
base_max_hp: 30,
hp: 30,
base_defense: 2,
base_power: 8,
xp: 100,
on_death: DeathCallback::Monster,
});
troll.ai = Some(Ai::Basic);
troll
The values here are basically tuning the gameplay. Feel free to set them to whatever you wish.
We’ll set sword’s power_bonus
to 3
and leave the rest set to 0
.
Next, we’ll add a shield which has a defense bonus of 1
.
Let’s rename the Equipment
item type to Sword
and add a new one
called Shield
:
enum Item {
Heal,
Lightning,
Confuse,
Fireball,
Sword, (1)
Shield, (2)
}
1 | Equipment → Sword |
2 | This is new |
and in use_item
:
let on_use = match item {
Heal => cast_heal,
Lightning => cast_lightning,
Confuse => cast_confuse,
Fireball => cast_fireball,
Sword => toggle_equipment, (1)
Shield => toggle_equipment, (2)
};
1 | Equipment → Sword |
2 | This is new |
And assign them chances in place_objects
. We’ll use our
from_dungeon_level
method here to only show these items later in the game:
// item random table
let item_chances = &mut [
...
Weighted {
weight: from_dungeon_level(&[Transition { level: 4, value: 5 }], level),
item: Item::Sword, (1)
},
Weighted {
weight: from_dungeon_level(
&[Transition {
level: 8,
value: 15,
}],
level,
),
item: Item::Shield, (2)
},
];
1 | This replaces Item::Equipment |
2 | This is new |
and further down in the match
expression we’ll actually create the
shield:
Item::Shield => {
// create a shield
let mut object = Object::new(x, y, '[', "shield", DARKER_ORANGE, false);
object.item = Some(Item::Shield);
object.equipment = Some(Equipment {
equipped: false,
slot: Slot::LeftHand,
max_hp_bonus: 0,
defense_bonus: 1,
power_bonus: 0,
});
object
}
And we need to update our sword creation code too:
Item::Sword => {
// create a sword
let mut object = Object::new(x, y, '/', "sword", SKY, false);
object.item = Some(Item::Sword);
object.equipment = Some(Equipment {
equipped: false,
slot: Slot::RightHand,
max_hp_bonus: 0,
defense_bonus: 0,
power_bonus: 3,
});
object
}
And finally, let’s give our player something to start with. They can’t go into the dungeon unarmed, after all!
So in new_game
after we initialise the Game
struct:
// initial equipment: a dagger
let mut dagger = Object::new(0, 0, '-', "dagger", SKY, false);
dagger.item = Some(Item::Sword);
dagger.equipment = Some(Equipment {
equipped: true,
slot: Slot::LeftHand,
max_hp_bonus: 0,
defense_bonus: 0,
power_bonus: 2,
});
game.inventory.push(dagger);
But let’s also decrease player’s initial power to 2
since this is
a dungeon of doom after all!
player.fighter = Some(Fighter {
base_max_hp: 100,
hp: 100,
base_defense: 1,
base_power: 2,
xp: 0,
on_death: DeathCallback::Player,
});
And that’s it. We’ve got a bonus system that’s generic enough for all kinds of crazy equipment. So: play the game, add stuff, change stuff, modify it to your heart’s content or write something completely new from scratch.
Have fun!
Here’s the complete code.