Part 13 - Equipment
This is the final part of the tutorial, in which we’ll add equipment.
This part is loosely based on this part of the python tcod tutorial.
Reference implementation branch for starting point: part-12-end
In this post:
- Equipment Entities
- Equipable Equipment
- Modifying Stats with Equipment
- Balance Item Distribution
- Log Message for Equipment
Equipment Entities
Add additional item types to represent equipment.
// world.rs
...
pub enum ItemType {
...
Sword,
Staff,
Armour,
Robe,
}
impl ItemType {
pub fn name(self) -> &'static str {
match self {
...
Self::Sword => "sword",
Self::Staff => "staff",
Self::Armour => "armour",
Self::Robe => "robe",
}
}
}
...
impl World {
...
pub fn maybe_use_item(
&mut self,
character: Entity,
inventory_index: usize,
message_log: &mut Vec<LogMessage>,
) -> Result<ItemUsage, ()> {
...
let usage = match item_type {
ItemType::HealthPotion => {
...
}
ItemType::FireballScroll | ItemType::ConfusionScroll => ItemUsage::Aim,
ItemType::Sword | ItemType::Staff | ItemType::Armour | ItemType::Robe => todo!(),
};
...
}
pub fn maybe_use_item_aim(
&mut self,
character: Entity,
inventory_index: usize,
target: Coord,
message_log: &mut Vec<LogMessage>,
) -> Result<(), ()> {
...
match item_type {
ItemType::HealthPotion
| ItemType::Sword
| ItemType::Staff
| ItemType::Armour
| ItemType::Robe => panic!("invalid item for aim"),
ItemType::FireballScroll => {
...
}
ItemType::ConfusionScroll => {
...
}
}
...
}
...
}
Add equipment to the item probability distribution. Set the probability of each to 1000 for now so it’s highly likely that all items will be equipment. This will help us test.
// terrain.rs
...
fn make_item_probability_distribution(level: u32) -> Vec<(ItemType, u32)> {
use ItemType::*;
vec![
(HealthPotion, 20),
(
FireballScroll,
match level {
0..=1 => 1,
2..=4 => 5,
_ => 10,
},
),
(
ConfusionScroll,
match level {
0..=1 => 1,
2..=4 => 3,
_ => 5,
},
),
(Sword, 1000),
(Staff, 1000),
(Armour, 1000),
(Robe, 1000),
]
}
...
Add some code for rendering the new item types.
// app.rs
...
pub mod colours {
...
pub const SWORD: Rgb24 = Rgb24::new(187, 187, 187);
pub const STAFF: Rgb24 = Rgb24::new(187, 127, 255);
pub const ARMOUR: Rgb24 = Rgb24::new(127, 127, 127);
pub const ROBE: Rgb24 = Rgb24::new(127, 127, 187);
...
pub fn item_colour(item_type: ItemType) -> Rgb24 {
match item_type {
...
ItemType::Sword => SWORD,
ItemType::Staff => STAFF,
ItemType::Armour => ARMOUR,
ItemType::Robe => ROBE,
}
}
...
}
fn currently_visible_view_cell_of_tile(tile: Tile) -> ViewCell {
match tile {
...
Tile::Item(ItemType::Sword) => ViewCell::new()
.with_bold(true)
.with_character('/')
.with_foreground(colours::SWORD),
Tile::Item(ItemType::Staff) => ViewCell::new()
.with_bold(true)
.with_character('\\')
.with_foreground(colours::STAFF),
Tile::Item(ItemType::Armour) => ViewCell::new()
.with_bold(true)
.with_character(']')
.with_foreground(colours::ARMOUR),
Tile::Item(ItemType::Robe) => ViewCell::new()
.with_bold(true)
.with_character('}')
.with_foreground(colours::ROBE),
...
}
}
The game will now populate levels with equipment.
Since equipment are just regular items, they can already be picked up, dropped, and viewed in
the inventory. Attempting to use a piece of equipment will panic at the moment (the todo!()
macro).
Reference implementation branch: part-13.0
Equipable Equipment
We’ll allow equipment to be equipped in two slots. Armour and robes go in the “worn” slot,
and swords and staffs go in the “held” slot.
We’ll keep track of whit is equipped by adding two components - equipment_worn_inventory_index
and
equipment_held_inventory_index
- which will store the index in the player’s inventory containing the
items equipped in the respective slots.
// world.rs
...
entity_table::declare_entity_module! {
components {
...
equipment_worn_inventory_index: usize,
equipment_held_inventory_index: usize,
}
}
...
Replace the todo!()
from the previous section to allow equipment items to be used from the inventory menu,
which will cause the item to be equipped.
// world.rs
...
impl World {
...
pub fn maybe_use_item(
&mut self,
character: Entity,
inventory_index: usize,
message_log: &mut Vec<LogMessage>,
) -> Result<ItemUsage, ()> {
...
let usage = match item_type {
ItemType::HealthPotion => {
...
}
ItemType::FireballScroll | ItemType::ConfusionScroll => ItemUsage::Aim,
ItemType::Sword | ItemType::Staff => {
self.components
.equipment_held_inventory_index
.insert(character, inventory_index);
ItemUsage::Immediate
}
ItemType::Armour | ItemType::Robe => {
self.components
.equipment_worn_inventory_index
.insert(character, inventory_index);
ItemUsage::Immediate
}
};
...
}
...
}
Update the logic for dropping items so that equipped items are unequipped if they are dropped.
// world.rs
...
impl World {
...
pub fn maybe_drop_item(
&mut self,
character: Entity,
inventory_index: usize,
message_log: &mut Vec<LogMessage>,
) -> Result<(), ()> {
...
if self
.components
.equipment_held_inventory_index
.get(character)
.cloned()
== Some(inventory_index)
{
self.components
.equipment_held_inventory_index
.remove(character);
}
if self
.components
.equipment_worn_inventory_index
.get(character)
.cloned()
== Some(inventory_index)
{
self.components
.equipment_worn_inventory_index
.remove(character);
}
...
}
...
}
To tell the player what they currently have equipped, we’ll update the inventory menu to display
the equipment slot occupied by equipped items. Add a type to world.rs
representing the inventory
slot indices corresponding to equipped items (if any), and a method for returning the equipment
inventory slots of a given entity.
// world.rs
...
pub struct EquippedInventoryIndices {
pub worn: Option<usize>,
pub held: Option<usize>,
}
...
impl World {
...
pub fn equipped_inventory_indices(&self, entity: Entity) -> EquippedInventoryIndices {
let held = self
.components
.equipment_held_inventory_index
.get(entity)
.cloned();
let worn = self
.components
.equipment_worn_inventory_index
.get(entity)
.cloned();
EquippedInventoryIndices { held, worn }
}
...
}
Add a method to GameState
for querying the player’s EquippedInventoryIndices
.
// game.rs
...
use crate::world::{
EquippedInventoryIndices, HitPoints, Inventory, ItemType, ItemUsage, Location, NpcType,
Populate, ProjectileType, Tile, World,
};
...
impl GameState {
...
pub fn player_equipped_inventory_indices(&self) -> EquippedInventoryIndices {
self.world.equipped_inventory_indices(self.player_entity)
}
}
Update the rendering logic for the inventory menu to show the equipment slots corresponding to equipped items.
// app.rs
...
impl<'a> View<&'a AppData> for InventorySlotMenuView {
fn view<F: Frame, C: ColModify>(
&mut self,
data: &'a AppData,
context: ViewContext<C>,
frame: &mut F,
) {
...
let equipped_indices = data.game_state.player_equipped_inventory_indices();
for ((i, entry, maybe_selected), &slot) in data
.inventory_slot_menu
.menu_instance()
.enumerate()
.zip(player_inventory_slots.into_iter())
{
...
let prefix = format!("{} {}) ", selected_prefix, entry.key);
let equipment_suffix = if equipped_indices.held == Some(i) {
" (held)"
} else if equipped_indices.worn == Some(i) {
" (worn)"
} else {
""
};
let text = &[
...
RichTextPart {
text: equipment_suffix,
style: name_style,
},
];
...
}
}
}
Now you can equip items by selecting them from the inventory. The name of their occupied equipment slot will appear next to items in the inventory menu.
Reference implementation branch: part-13.1
Modifying Stats with Equipment
Right now you can equip items, but equipping an item doesn’t actually change the game at all. Let’s make each item increase combat stats by a flat amount.
Add some methods to World
for computing modifiers to various combat stats based on equipment.
The effect of equipping different items will be hard-coded into these methods.
// world.rs
...
impl World {
...
fn inventory_item_type(&self, entity: Entity, index: usize) -> Option<ItemType> {
self.components.inventory.get(entity).and_then(|inventory| {
inventory
.get(index)
.ok()
.and_then(|held_entity| self.components.item.get(held_entity).cloned())
})
}
fn damage_modifier(&self, entity: Entity) -> i32 {
self.components
.equipment_held_inventory_index
.get(entity)
.and_then(|&held_index| {
self.inventory_item_type(entity, held_index)
.map(|item_type| match item_type {
ItemType::Sword => 1,
_ => 0,
})
})
.unwrap_or(0)
}
fn defense_modifier(&self, entity: Entity) -> i32 {
self.components
.equipment_worn_inventory_index
.get(entity)
.and_then(|&held_index| {
self.inventory_item_type(entity, held_index)
.map(|item_type| match item_type {
ItemType::Armour => 1,
_ => 0,
})
})
.unwrap_or(0)
}
fn magic_modifier(&self, entity: Entity) -> i32 {
let held = self
.components
.equipment_held_inventory_index
.get(entity)
.and_then(|&held_index| {
self.inventory_item_type(entity, held_index)
.map(|item_type| match item_type {
ItemType::Staff => 1,
_ => 0,
})
})
.unwrap_or(0);
let worn = self
.components
.equipment_worn_inventory_index
.get(entity)
.and_then(|&held_index| {
self.inventory_item_type(entity, held_index)
.map(|item_type| match item_type {
ItemType::Robe => 1,
_ => 0,
})
})
.unwrap_or(0);
held + worn
}
...
}
Update World::character_bump_attack
to take modifiers into account.
// world.rs
...
impl World {
...
fn character_bump_attack<R: Rng>(
&mut self,
victim: Entity,
attacker: Entity,
rng: &mut R,
) -> BumpAttackOutcome {
let &attacker_base_damage = self.components.base_damage.get(attacker).unwrap();
let &attacker_strength = self.components.strength.get(attacker).unwrap();
let attacker_damage_modifier = self.damage_modifier(attacker);
let &victim_dexterity = self.components.dexterity.get(victim).unwrap();
let victim_defense_modifier = self.defense_modifier(victim);
let gross_damage = attacker_base_damage
+ rng.gen_range(0..(attacker_strength + 1))
+ attacker_damage_modifier;
let damage_reduction = rng.gen_range(0..(victim_dexterity + 1)) + victim_defense_modifier;
let net_damage = gross_damage.saturating_sub(damage_reduction).max(0) as u32;
...
}
...
}
Add a method World::magic
which computes a magic score based on intelligence and the magic modifier,
and use this to determine the power of spells.
// world.rs
...
impl World {
...
fn magic(&self, entity: Entity) -> i32 {
self.components
.intelligence
.get(entity)
.cloned()
.unwrap_or(0)
+ self.magic_modifier(entity)
}
pub fn maybe_use_item_aim(
&mut self,
character: Entity,
inventory_index: usize,
target: Coord,
message_log: &mut Vec<LogMessage>,
) -> Result<(), ()> {
...
match item_type {
...
ItemType::FireballScroll => {
let fireball = ProjectileType::Fireball {
damage: self.magic(character).max(0) as u32,
};
message_log.push(LogMessage::PlayerLaunchesProjectile(fireball));
self.spawn_projectile(character_coord, target, fireball);
}
ItemType::ConfusionScroll => {
let confusion = ProjectileType::Confusion {
duration: self.magic(character).max(0) as u32 * 3,
};
message_log.push(LogMessage::PlayerLaunchesProjectile(confusion));
self.spawn_projectile(character_coord, target, confusion);
}
}
...
}
...
}
Reference implementation branch: part-13.2
Balance Item Distribution
Replace the debugging values in make_item_probability_distribution
with smaller values.
To further reduce the odds of an item being a piece of equipment, increase the probability of
the other items too. Make the odds of finding equipment go up the deeper the player is in the dungeon.
// terrain.rs
...
fn make_item_probability_distribution(level: u32) -> Vec<(ItemType, u32)> {
use ItemType::*;
let item_chance = match level {
0..=1 => 5,
2..=3 => 10,
_ => 20,
};
vec![
(HealthPotion, 200),
(
FireballScroll,
match level {
0..=1 => 10,
2..=4 => 50,
_ => 100,
},
),
(
ConfusionScroll,
match level {
0..=1 => 10,
2..=4 => 30,
_ => 50,
},
),
(Sword, item_chance),
(Staff, item_chance),
(Armour, item_chance),
(Robe, item_chance),
]
}
...
These numbers were chosen fairly arbitrarily. Tune these based on the result of play-testing.
Reference implementation branch: part-13.3
Log Message for Equipment
One final touch - adding a log message when the player equips an item.
// game.rs
...
pub enum LogMessage {
...
PlayerEquips(ItemType),
}
// world.rs
...
impl World {
...
pub fn maybe_use_item(
&mut self,
character: Entity,
inventory_index: usize,
message_log: &mut Vec<LogMessage>,
) -> Result<ItemUsage, ()> {
...
let usage = match item_type {
ItemType::HealthPotion => {
...
}
ItemType::FireballScroll | ItemType::ConfusionScroll => ItemUsage::Aim,
ItemType::Sword | ItemType::Staff => {
self.components
.equipment_held_inventory_index
.insert(character, inventory_index);
message_log.push(LogMessage::PlayerEquips(item_type));
ItemUsage::Immediate
}
ItemType::Armour | ItemType::Robe => {
self.components
.equipment_worn_inventory_index
.insert(character, inventory_index);
message_log.push(LogMessage::PlayerEquips(item_type));
ItemUsage::Immediate
}
};
...
}
...
}
// ui.rs
...
impl<'a> View<&'a [LogMessage]> for MessagesView {
fn view<F: Frame, C: ColModify>(
&mut self,
messages: &'a [LogMessage],
context: ViewContext<C>,
frame: &mut F,
) {
fn format_message(buf: &mut [RichTextPartOwned], message: LogMessage) {
...
match message {
...
PlayerEquips(item_type) => {
write!(&mut buf[0].text, "You equip the ").unwrap();
write!(&mut buf[1].text, "{}", item_type.name()).unwrap();
buf[1].style.foreground = Some(colours::item_colour(item_type));
write!(&mut buf[2].text, ".").unwrap();
}
}
}
...
}
}
...
That concludes the tutorial series. There’s still a lot more work to do before this game can be considered complete.
It has no ending, and is very light on content and mechanics. Hopefully by now you have enough of a handle on
chargrid
that you can extend the project we made here into the roguelike of your dreams.
Reference implementation branch: part-13.4