Yet Another Roguelike Tutorial, Part 13

Publish date: Dec 17, 2023
Tags: godot4 tutorial roguelike

Part 13: Gearing Up

Welcome to the last part of the roguelike tutorial series. You’ve made it all the way here through the 12 previous parts. Speaking of, you can find the previous part here: https://selinadev.github.io/16-rogueliketutorial-12/

In this final part of the series we will expand our players' options once more. By now they can gain XP and permanently level up their stats by slaying monsters, and they can find single-use items in the dungeon. This time we will tackle equipment, allowing players to find items that will increase their stats while they wear them.

To start things off, we will start on a new component. Create a new script extenting Component at res://src/Entities/Actors/Components/equippable_component.gd. Here’s the script:

class_name EquippableComponent
extends Component

enum EquipmentType { WEAPON, ARMOR }

var equipment_type: EquipmentType
var power_bonus: int
var defense_bonus: int


func _init(definition: EquippableComponentDefinition) -> void:
	equipment_type = definition.equipment_type
	power_bonus = definition.power_bonus
	defense_bonus = definition.defense_bonus

First, we define an equipment type enum. This will correspond to the different equipment slots, so the player will be allowed to have one weapon and one armor equipped (so no dual wielding in this implementation). As you can see in the variables, each equippable item has an equipment type, and can give a bonus to power and/or defense. The _init() function simply initializes these values from a corresponding definition resource. Let’s set that one up now. Create an new script extending rResource at res://src/Entities/Actors/Components/ComponentDefinitions/equippable_component_definition.gd. This script defines exported values for those three variables:

class_name EquippableComponentDefinition
extends Resource

@export var equipment_type: EquippableComponent.EquipmentType
@export var power_bonus: int = 0
@export var defense_bonus: int = 0

We won’t change these values, meaning eqippable components don’t have state, and as such won’t need special consideration in the save system, but should work as they are. The equippable component will work in conjunction with an equipment component, which we will code later. However, we can already think about items using that component. In our simple system an item will either have a consumable component or an equippable component. We will use the same item interaction system for equipping and unequipping we have used for consumables, so having both on an item would mean the game won’t know if you intend to consume an item or equip it.

We will embed that restriction in our entity definition system, by changing the expected types of the exported variable. This will show both a strength and a weakness of Godot’s resource system. As a first step, create a new script extending Resource at res://src/Entities/Actors/Components/ComponentDefinitions/item_component_definition.gd. Here’s the whole script:

class_name ItemComponentDefinition
extends Resource

That’s it. Just like with the consumable definition, we only need this as a base type. Speaking of which, change consumable_component_definition.gd as follows:

class_name ConsumableComponentDefinition
extends ItemComponentDefinition

Equippables are also items, so we change the top of equippable_component_definition.gd again right away:

class_name EquippableComponentDefinition
extends ItemComponentDefinition

Now, in entity_definition.gd we remove consumable_defintion and replace it with an item_definition:

@export_category("Components")
@export var fighter_definition: FighterComponentDefinition
@export var ai_type: Entity.AIType
@export var item_definition: ItemComponentDefinition
@export var inventory_capacity: int = 0
@export var level_info: LevelComponentDefinition

This new variable is of type ItemComponentDefinition, meaning it can hold a definition for any of the consumables we have, as well as one for an equippable item. And the nice thing is that when we create a sub-resource for that variable in the inspector, we will get offered just these options (as we had with just the consumables). I really like that part of the resource system. Now, what I don’t like is that we just lost some data. All the consumable definitions were stored in the variable consumable_definition, which no longer exists. Godot cannot know that we are refactoring that same information to the item_definition variable. Of course, we could have just changed the type of consumable_definition, but that name would have been misleading. The effect of this is that we have to reopen our existing item definitions and restore the component definitions we had on them previously. I’ll quickly go over them:

confusion_scroll_definition.tres: ConfusionConsumableComponentDefinition

property value
Number of Turns 10

fireball_scroll_definition.tres: FireballDamageConsumableComponentDefinition

property value
Damage 12
Radius 3

health_potion_definition.tres: HealingConsumableComponentDefinition

property value
Healing Amount 4

lightning_scroll_definition.tres: LightningDamageConsumableComponentDefinition

property value
Damage 20
Maximum Range 5

And with that the functionality of our existing items is restored. Now let’s create some equippable items. Save them next to the other item definitions and choose appropriate icons for them, as usual:

dagger_definition.tres:

property value
Name Dagger
Color #7f7f7f
Is Blocking false
Type Item
Item Definition EquippableComponentDefinition
Equipment Type Weapon
Power Bonus 2
Defense Bonus 0

sword_definition.tres:

property value
Name Sword
Color #7f7f7f
Is Blocking false
Type Item
Item Definition EquippableComponentDefinition
Equipment Type Weapon
Power Bonus 4
Defense Bonus 0

leather_armor_definition.tres:

property value
Name Leather Armor
Color #ff7f00
Is Blocking false
Type Item
Item Definition EquippableComponentDefinition
Equipment Type Armor
Power Bonus 0
Defense Bonus 1

chainmail_definition.tres:

property value
Name Chainmail
Color #7f7f7f
Is Blocking false
Type Item
Item Definition EquippableComponentDefinition
Equipment Type Armor
Power Bonus 0
Defense Bonus 3

There’s three places in our code we need to update to integrate our new shiny toys into the existing game (if only as dumb items without any functionality yet). First, open entity.gd and update the list of entity_types:

const entity_types = {
	"player": "res://assets/definitions/entities/actors/entity_definition_player.tres",
	"orc": "res://assets/definitions/entities/actors/entity_definition_orc.tres",
	"troll": "res://assets/definitions/entities/actors/entity_definition_troll.tres",
	"health_potion": "res://assets/definitions/entities/items/health_potion_definition.tres",
	"lightning_scroll": "res://assets/definitions/entities/items/lightning_scroll_definition.tres",
	"confusion_scroll": "res://assets/definitions/entities/items/confusion_scroll_definition.tres",
	"fireball_scroll": "res://assets/definitions/entities/items/fireball_scroll_definition.tres",
	"dagger": "res://assets/definitions/entities/items/dagger_definition.tres",
	"sword": "res://assets/definitions/entities/items/sword_definition.tres",
	"chainmail": "res://assets/definitions/entities/items/chainmail_definition.tres",
	"leather_armor": "res://assets/definitions/entities/items/leather_armor_definition.tres",
}

Next, we update our spawn tables in dungeon_generator.gd:

const item_chances = {
	0: {"health_potion": 35},
	2: {"confusion_scroll": 10},
	4: {"lightning_scroll": 25, "sword": 5},
	6: {"fireball_scroll": 25, "chainmail": 15},
}

We will have the player spawn with a dagger and a leather armor, so we only entered the sword and chainmail. However, that’s all the work we have to do here, our spawning system is flexible enough by now to handle the rest on its own. Swords and chainmails will now appear once the player is deep enough into the dungeon, but we’ll need one more change in order for them to be able to pick them up. We need to expand the get_items() function in map_data.gd as follows:

func get_items() -> Array[Entity]:
	var items: Array[Entity] = []
	for entity in entities:
		if entity.consumable_component != null or entity.equippable_component != null:
			items.append(entity)
	return items

So far it only checked for the presence of a consumable component, but now we also allow picking up entities with equippable components. You can now find and pick up equipment, but you can’t equip it, so let’s take care of that now. We will create a new equipment component for that. Create a new script extending Component at res://src/Entities/Actors/Components/equipment_component.gd. Here’s the very top of it:

class_name EquipmentComponent
extends Component

signal equipment_changed

var slots := {}

We will later use the equipment_changed signal to notify our stats display when the equipment has changed, so it can update accordingly. We also have a slots variable that’s currently an empty dictionary. We don’t need to create variables for each slot, because we can just use the values of the EquipmentType enum as keys. As mentioned earlier that means no dual wielding, and no multiple rings and the like. You’d need a more complex system for that. However, if you want helmets you only need to add an entry for that in EquipmentType and just like that the system will work for helmets as well.

Equipment will give boni to the power and defense stats, and the equipment component is responsible to tell us how much:

func get_defense_bonus() -> int:
	var bonus = 0
	
	for item in slots.values():
		if item.equippable_component:
			bonus += item.equippable_component.defense_bonus
	
	return bonus


func get_power_bonus() -> int:
	var bonus = 0
	
	for item in slots.values():
		if item.equippable_component:
			bonus += item.equippable_component.power_bonus
	
	return bonus

Both these functions simply iterate over all equipped items and add the appropriate bonus to a counter, which we then return. Next, we want to be able to know whether an item is equipped:

func is_item_equipped(item: Entity) -> bool:
	return item in slots.values()

Here we simply check if the item is part of the values of the slots dictionary. And now for the actual equipping and unequipping. We start with unequip:

func _unequip_from_slot(slot: EquippableComponent.EquipmentType, add_message: bool) -> void:
	var current_item = slots.get(slot)
	
	if add_message:
		MessageLog.send_message("You remove the %s." % current_item.get_entity_name(), Color.WHITE)
	
	slots.erase(slot)
	
	equipment_changed.emit()

We get an equipment type as an argument that tells us which slot we will unequip from, as well as a boolean argument that tells us whether we want to log the unequipping to the message log (this will be useful later for both the starting equipment and the save system). Then in the function we get the current item. Note that the way we will call this function should ensure that we will only ever try to unequip from a slot that actually has an item, otherwise we should probably add checks here to abort the function if that slot does not hold an item. Next, we log the message if we specified so in the arguments and then erase the slot from the dictionary. Lastly, we emit the equipment_changed signal, so the ui can show the new resulting stats. Now to equipping, which has only one more step:

func _equip_to_slot(slot: EquippableComponent.EquipmentType, item: Entity, add_message: bool) -> void:
	var current_item = slots.get(slot)
	if current_item:
		unequip_from_slot(slot, add_message)
	slots[slot] = item
	if add_message:
		MessageLog.send_message("You equip the %s." % item.get_entity_name(), Color.WHITE)
	
	equipment_changed.emit()

When we equip we don’t know whether or not there’s currently an item in that slot. So we first try to get it. If we don’t have that slot, slots.get() will return null. If we do get an item, we call unequip_from_slot() on it. Now the slot is free, and we can enter the item we passed into the function to it. Then we can optionally log this event and, again, emit the signal. However, our interface is very minimal, and when interacting with the inventory we basically can only tell the game “do something with this item”. So we allow the equipment component to figure out what it has to do with an item with the following function:

func toggle_equip(equippable_item: Entity, add_message: bool = true) -> void:
	if not equippable_item.equippable_component:
		return
	var slot: EquippableComponent.EquipmentType = equippable_item.equippable_component.equipment_type
	
	if slots.get(slot) == equippable_item:
		_unequip_from_slot(slot, add_message)
	else:
		_equip_to_slot(slot, equippable_item, add_message)

The toggle_equip() function first checks if the entity we passed it actually has an equippable component, and if not it returns early. If we have an equippable component on the item, we can retrieve the equipment type, and thereby the appropriate slot for it. We then check if the item we passed in already is in that slot. If so then the player must want to unequip it. If it is not equipped there, we equip it (and if something else is equipped there already, the _equip_to_slot() function will take care of unequipping that item first). Lastly, we don’t want the player just letting go of all equipped items whenever they load the game, so we prepare the interface for our save system:

func get_save_data() -> Dictionary:
	var equipped_indices := []
	var inventory: InventoryComponent = entity.inventory_component
	for i in inventory.items.size():
		var item: Entity = inventory.items[i]
		if is_item_equipped(item):
			equipped_indices.append(i)
	return {"equipped_indices": equipped_indices}

We don’t want to store the items directly in the equipment system, they are still in the inventory. So what we do is we get the inventory and just save the indices of all equipped items.

func restore(save_data: Dictionary) -> void:
	var equipped_indices: Array = save_data["equipped_indices"]
	var inventory: InventoryComponent = entity.inventory_component
	for i in inventory.items.size():
		if equipped_indices.any(func(index): return int(index) == i):
			var item: Entity = inventory.items[i]
			toggle_equip(item, false)

To restore the equipment we just need to retrieve the inventory, go through it and equip all the items at the inventory indices that we retrieved. I want to point out two things here. First, note that this introduces a loading dependency, which we did not have on any component before. If you shuffle around the order in which you restore components on entities, you should still get pretty much the same entity. However, with the equipment component we need to make sure to restore the inventory component before it. Things should work as long as we remember to do that, but I wanted to point that out anyway in case you create your own components with dependencies. The second thing is the slightly odd seeming code of if equipped_indices.any(func(index): return int(index) == i). Initially I tried to use if i in equipped_indices. However, that didn’t work, and I assume it’s because when loading from json it reads in all numbers as float. I therefore used the any() function, which is a function on arrays that takes in a function that returns a boolean, runs it on everything in the array, and itself returns true if any of those function calls returns true. I passed it an anonymous function here, i.e., a function I defined directly as the function argument. That function explicitly turns the stored index into an integer before comparing. So, the effect of that line is that we ask if i is equal to the integer value of any of the indices in equipped_indices. If so, we retrieve it from the inventory and equip it (without a log message).

That’s the equipment component done. Our next step is to integrate it with the entity and the other affected components (i.e., the fighter component). Let’s update our entity_difinition.gd:

@export_category("Components")
@export var fighter_definition: FighterComponentDefinition
@export var ai_type: Entity.AIType
@export var item_definition: ItemComponentDefinition
@export var inventory_capacity: int = 0
@export var level_info: LevelComponentDefinition
@export var has_equipment: bool = false

We only need to know whether or not an entity should have an equipment component or not, and most entities should not have one. In fact, in our game only the player should, so we open entity_definition_player.tres and tick Has Equipment. Now to the entity itself. Open entity.gd and start by adding a slot for the new component:

var fighter_component: FighterComponent
var ai_component: BaseAIComponent
var consumable_component: ConsumableComponent
var equippable_component: EquippableComponent
var inventory_component: InventoryComponent
var level_component: LevelComponent
var equipment_component: EquipmentComponent

Next, in set_entity_type() we need to create it, if the definition tells us to:

	if entity_definition.level_info:
		level_component = LevelComponent.new(entity_definition.level_info)
		add_child(level_component)
	
	
	if entity_definition.has_equipment:
		equipment_component = EquipmentComponent.new()
		add_child(equipment_component)
		equipment_component.entity = self

Lastly, the save system:

func get_save_data() -> Dictionary:
	# ...
	if fighter_component:
		save_data["fighter_component"] = fighter_component.get_save_data()
	if ai_component:
		save_data["ai_component"] = ai_component.get_save_data()
	if inventory_component:
		save_data["inventory_component"] = inventory_component.get_save_data()
	if equipment_component:
		save_data["equipment_component"] = equipment_component.get_save_data()
	if level_component:
		save_data["level_component"] = level_component.get_save_data()
	return save_data

func restore(save_data: Dictionary) -> void:
	# ...
	if inventory_component and save_data.has("inventory_component"):
		inventory_component.restore(save_data["inventory_component"])
	if equipment_component and save_data.has("equipment_component"):
		equipment_component.restore(save_data["equipment_component"])
	if level_component and save_data.has("level_component"):
		level_component.restore(save_data["level_component"])

As mentioned, it is important, at least for the restore() function, that we restore the equipment after we are done restoring the inventory. Now, for equipment to actually have an effect, we need to account for the equipment bonus in fighter.gd. We change some variables:

var base_defense: int
var base_power: int
var defense: int: 
	get:
		return base_defense + get_defense_bonus()
var power: int: 
	get:
		return base_power + get_power_bonus()

We will use the values we previously had as base_defense and base_power. In order to minimize refactoring with the rest of the code, we still use defense and power for the results. But we use a trick. By using a get method we effectively make it so that when this property is accessed, it will return the sum of the base_defense or base_power and the corresponding bonus. Here’s how we calculate those boni:

func get_defense_bonus() -> int:
	if entity.equipment_component:
		return entity.equipment_component.get_defense_bonus()
	return 0


func get_power_bonus() -> int:
	if entity.equipment_component:
		return entity.equipment_component.get_power_bonus()
	return 0

To calculate the bonus we check if the entity has an equipment component. If it does, we return the bonus the equipment gives. If the entity doesn’t have a bonus, we return 0, and the entity uses just the base stat. We need to change our initialization to set the base stats now:

func _init(definition: FighterComponentDefinition) -> void:
	max_hp = definition.max_hp
	hp = definition.max_hp
	base_defense = definition.defense
	base_power = definition.power
	death_texture = definition.death_texture
	death_color = definition.death_color

We also have to change the code for saving and restoring to affect the base stats:

func get_save_data() -> Dictionary:
	return {
		"max_hp": max_hp,
		"hp": hp,
		"power": base_power,
		"defense": base_defense
	}


func restore(save_data: Dictionary) -> void:
	max_hp = save_data["max_hp"]
	hp = save_data["hp"]
	base_power = save_data["power"]
	base_defense = save_data["defense"]

Now the fighter component can properly coordinate with the equipment. The last (mechanical) piece of the puzzle is a way to actually interact with equipment in the inventory. Interacting with equipment is an action, so we create a new action. For that we create a new script extending Action at res://src/Entities/Actors/Actions/equip_action.gd. Here’s the code for that:

class_name EquipAction
extends Action

var _item: Entity


func _init(entity: Entity, item: Entity) -> void:
	super._init(entity)
	_item = item


func perform() -> bool:
	entity.equipment_component.toggle_equip(_item)
	return true

You see that we return true for this action, meaning changing equipment costs a turn. Now we need a place to create this action. We already have the ItemAction, and just like we do in the BumpAction we can use that to change to another action on the fly. So we change the perform() function of item_action.gd to the following:

func perform() -> bool:
	if item == null:
		return false
	if item.equippable_component:
		return EquipAction.new(entity, item).perform()
	return item.consumable_component.activate(self)

If we have an equippable component, we create an EquipAction, perform it and return the result. One other place that should interact with the equipment is dropping items. We don’t want to keep an item equipped that we drop. We update drop_item_action.gd to check if the item is equipped, and if so, we unequip it before dropping it:

func perform() -> bool:
	if item == null:
		return false
	if entity.equipment_component and entity.equipment_component.is_item_equipped(item):
		entity.equipment_component.toggle_equip(item)
	entity.inventory_component.drop(item)
	return true

With that equipping should work now, just by interacting with the inventory, same as with consumables. To test that, we can give the player some starting equipment. We create a new function in game.gd:

func _add_player_start_equipment(item_key: String) -> void:
	var item := Entity.new(null, Vector2i.ZERO, item_key)
	player.inventory_component.items.append(item)
	player.equipment_component.toggle_equip(item, false)

This function takes a string, then spawns an entity of that type, puts it in the player’s inventory and then equips it (silently). We call this right after we create the player:

func new_game() -> void:
	player = Entity.new(null, Vector2i.ZERO, "player")
	_add_player_start_equipment("dagger")
	_add_player_start_equipment("leather_armor")
	# ...

Now the player starts with a dagger and leather armor. If you start the game now you should be able to go into the inventory and select these items. Doing so should create messages in the message log telling you that you equipped or unequipped an item. However, our user interface has two issues at the moment. The first is that we have now way of knowing if an item is currently equipped. And the other is that the nice stats display we have below the HP and XP bars does not properly update. We will tackle these issues in that order.

To keep things simple we will indicate equipped items in the inventory list by marking them with “(E)”. We do this in inventory_meny.gd:

func _register_item(index: int, item: Entity, is_equipped: bool) -> void:
	var item_button: Button = inventory_menu_item_scene.instantiate()
	var char: String = String.chr("a".unicode_at(0) + index)
	item_button.text = "( %s ) %s" % [char, item.get_entity_name()]
	if is_equipped:
		item_button.text += " (E)"
	var shortcut_event := InputEventKey.new()
	shortcut_event.keycode = KEY_A + index
	item_button.shortcut = Shortcut.new()
	item_button.shortcut.events = [shortcut_event]
	item_button.pressed.connect(button_pressed.bind(item))
	inventory_list.add_child(item_button)

The _register_item() function now takes an argument telling it if the item is equipped. If that’s the case, we add our equipped marker to the end of the button’s text. We can check in build() whether the items we iterate through are equipped:

func build(title_text: String, inventory: InventoryComponent) -> void:
	if inventory.items.is_empty():
		button_pressed.call_deferred()
		MessageLog.send_message("No items in inventory.", GameColors.IMPOSSIBLE)
		return
	var equipment: EquipmentComponent = inventory.entity.equipment_component
	title_label.text = title_text
	for i in inventory.items.size():
		var item: Entity = inventory.items[i]
		var is_equipped: bool = equipment.is_item_equipped(item)
		_register_item(i, item, is_equipped)
	inventory_list.get_child(0).grab_focus()
	show()

We now need to get the equipment component. Then, when we go through the items we check for each item if it’s in the equipment, and pass that result to _register_item(). If you run the game now, you will see that our starting equipment is shown as equipped, and if you unequip it, that marker is no longer shown.

The second issue is the stats display. We handle that in character_info_box.gd. update_labels() will pull the current values, and with the way the fighter component works now, this includes boni from equipment. So we just need to tell it that in addition to level ups it also needs to update when the equipment changes:

func setup(player: Entity) -> void:
	_player = player
	_player.level_component.leveled_up.connect(update_labels)
	_player.equipment_component.equipment_changed.connect(update_labels)
	update_labels()

Unfortunately, with the way we do the starting equipment, this means that update_labels() would be called before the player is ready. That means we need a short check at the start of that function:

func update_labels() -> void:
	if not _player.is_inside_tree():
		await _player.ready
	level_label.text = "LVL: %d" % _player.level_component.current_level
	attack_label.text = "ATK: %d" % _player.fighter_component.power
	defense_label.text = "DEF: %d" % _player.fighter_component.defense

If the player isn’t yet in the scene tree, we wait for it to be ready. And with that, the gui will update as well. If you start the game you can take off your already equipped starting equipment, and see the power and defense values update accordingly, always showing the current total.

And now we have equipment in our roguelike as well! That concludes this part, and with it the roguelike tutorial. I hope this was helpful to you, and I’m proud of you if you’ve made it this far. As before, you can find the full code of all parts in the GitHub repository: https://github.com/SelinaDev/Godot-Roguelike-Tutorial

If you find bugs or have problems, you can use the Issues or Discussions on the GitHub repo. You can also find me on Mastodon as @selinadev@indiepocalypse.social, or can talk to me on my discord: https://discord.gg/fXBdwVGh