Godot Tactics RPG – 10. Items and Equipment

7thSage again. Welcome back to part 10 of our tutorial. This time we’ll be working on setting up the ability to manage items and equipment. Last time we added stats, now lets make some more use of them. We’re now able to add things like increasing attack when we equip our sword, or recover health if we use a potion.

Feature

The main purpose of an item is to modify something, we’ll call this it’s feature. Every item will have at least one, or sometimes multiple features.

The base feature class will support using items in two main ways.

  1. We’ll be able to activate the effect of the item and later de-activate the effect. Such as equipping a piece of equipment, or a buff that lasts so many turns. We’ll use the Activate() and Deactivate() functions for these.
  2. The second use case is that we apply a permanent effect on the stat. Such as adding EXP to a unit after a battle, or HP after using a health potion. For this we’ll call the Apply() function.

We’ll implement what happens in OnApply() and OnRemove() in the classes that extend Feature.

Create a folder in “Scripts->View Model Component” named “Features” and inside it create a script named “Feature.gd”

extends Node
class_name Feature

var target:Node:
	get:
		return _target

var _target:Node

func Activate(targetObj:Node):
	if _target == null:
		_target = targetObj
		OnApply()

func Deactivate():
	if _target != null:
		OnRemove()
		_target = null
	

func Apply(targetObj:Node):
	_target = targetObj
	OnApply()
	_target = null

func OnApply():
	pass

func OnRemove():
	pass

Stat Modifier Feature

We’ll likely use many different types of features to modify a large array of things besides just stats. We may have something that revives an ally, or adds a status ailment or many other possibilities. For now though, we’ll focus on creating a feature to modify stat values, and in future lessons we may tackle some of the other possibilities.

This one is fairly simple. Here we can modify any stat type, and depending on the value we send, could be either a good or bad effect. This is also compatible whether we are using a consumable item, or something that we will equip.

Create a new script named “StatModifierFeature.gd” in the same folder as the previous script.

extends Feature
class_name StatModifierFeature

var type:StatTypes.Stat
var amount:int
var stats:Stats:
	get:
		return _target.get_node("Stats")

func OnApply():
	var startValue = stats.GetStat(type)
	stats.SetStat(type, startValue + amount)

func OnRemove():
	var startValue = stats.GetStat(type)
	stats.SetStat(type, startValue - amount)

Merchandise

While we are talking about items. At some point we’ll also need to have a way to acquire them. Here we can add an atribute to an item to track its buy and sell price. We won’t be using it in this lesson, but it may be useful in the future.

Create a folder named “Item” in the folder “Script->View Model Component” and inside create a script named “Merchandise.gd”

extends Node
class_name Merchandise

var buy:int
var sell:int

Consumable

We can give items the ability to be consumed by adding a Consumable component. We’ll call Apply() on any features attached to the object. Because the effects are not tied to an equipment status like equipping a piece of armor, we aren’t using Activate() or Deactivate(). We specify a target here because it shouldn’t be assumed that an item is being used on the user. We could be using the item on an ally or even an enemy, depending on the item. I’m using “.get_parent().get_children()” to get a list of all the siblings of a node. Our hierarchy for this lesson is set up like the following image.

Create a script named “Consumable.gd” in the folder “Scripts->View Model Component->Item”

extends Node
class_name Consumable

func Consume(targetObj:Node):
	var features:Array[Node] = []	
	features = self.get_parent().get_children()
	
	var filteredArray = features.filter(func(node):return node is Feature)
	for node in filteredArray:
		node.Apply(targetObj)

Equip Slots

Before we can work on equipping items, we need to have someplace to equip them to. We’ll create an enum that lists the various locations an item can be equipped, and like a previous lesson, we’ll use bit masks to identify the slot. This will let us do things like equip a sword two handed and know whether a shield is in the offhand that needs to be unequipped as well.

In the folder “Scripts->Enums Exentions” create a new script named “EquipSlots.gd”

class_name EquipSlots

enum Slot
{
	NONE = 0,
	PRIMARY = 1 << 0,
	SECONDARY = 1 << 1,
	HEAD = 1 << 2,
	BODY = 1 << 3,
	ACCESSORY = 1 << 4
}

Equippable

Each item that is equippable will have a list of features that will stay active as long as the item is equipped.

Create a new script named “Equippable.gd” in the folder “Scripts->View Model Component->Item”

The first three variables keep track of the slots of an item.

  • defaultSlots – This holds the primary slot a item will equip to. A sword might have the main hand as the primary, while a shield might use the off hand slot as the primary.
  • secondarySlots – Some items might have a secondary configuration to equip an item, such as a sword that can be used either single or two handed. Some weapons may also be equippable in the offhand to allow dual wielding.
  • slots – This is the actual current slot an item is equipped in.

When OnEquip() is called, it loops through all the children of the main node of the item and for all Feature objects it will apply the effect to the selected stat. OnUnEquip() will do the same thing, but apply the effect in reverse to remove it.

extends Node
class_name Equippable

var defaultSlots:EquipSlots.Slot
var secondarySlots:EquipSlots.Slot
var slots:EquipSlots.Slot

var _isEquipped:bool

func OnEquip():
	if _isEquipped:
		return
	
	_isEquipped = true
	
	var features:Array[Node] = self.get_parent().get_children()
	
	var filteredArray = features.filter(func(node):return node is Feature)
	for node in filteredArray:
		node.Activate(self.get_parent().get_parent().get_parent())


func OnUnEquip():
	if not _isEquipped:
		return
	
	_isEquipped = false
	
	var features:Array[Node] = self.get_parent().get_children()
	var filteredArray = features.filter(func(node):return node is Feature)
	for node in filteredArray:
		node.Deactivate()

Equipment

We add an Equippable node to items that we want to be equippable, but we also need a component to add to a character to manage what is equipped.

Create a script named “Equipment.gd” in the folder “Scripts->View Model Component->Actor”

Whenever our equipment is changed here we’ll emit a signal to notify other parts of our code that it has changed. When we equip an item, we’ll call UnEquipSlots() to remove any item that has overlapping slots.

extends Node
class_name Equipment

signal EquippedNotification()
signal UnEquippedNotification()

var _items:Array[Equippable]

func Equip(item:Equippable, slots:EquipSlots.Slot):
	UnEquipSlots(slots)
	
	_items.append(item)
	var itemParent:Node = item.get_parent()
	self.add_child(itemParent)
	item.slots = slots
	item.OnEquip()
	EquippedNotification.emit(self, item)

func UnEquipItem(item:Equippable):
	item.OnUnEquip()
	item.slots = EquipSlots.Slot.NONE
	_items.erase(item)
	var itemParent:Node = item.get_parent()
	self.remove_child(itemParent)
	UnEquippedNotification.emit(self, item)
	
func UnEquipSlots(slots:EquipSlots.Slot):
	for i in range(_items.size()-1,-1,-1):
		var item = _items[i]
		if (item.slots & slots) != EquipSlots.Slot.NONE:
			UnEquipItem(item)

Demo

Now that we’ve got quite a few new components, lets create another test similar to the last lesson. This time we’ll set up a mock battle with our item components that will play out in the console.

Create a new test script named “TestItems.gd” and attach it to a test Node in our scene. Just like last time, when we are done, delete or detach the script from our scene.

Should note that we won’t be creating items or characters like this once we get a few more things implemented, but this will suffice for the time being.

We create two arrays one will hold the characters for our battle, and the other will hold the items we’ll be using in our simulation. It should be noted that the characters here are still not the same ones on our battlefield. The items in the array when used will be deleted from the array. When equipped the items will similarly be deleted, but will also be added back when the item is un-equipped. We’ll keep a reference to our random number generator in _random. We don’t want to recreate this every time we need a random number. For one it would be slow, but also once we give the generator a seed value with “_random.randomize()” in _ready().

We set our simulation in motion in the _ready() function. Creating our characters and items with CreateItems() and CreateCombatants(). Once that setup is complete we call SimulateBattle()

OnEquippedItem() and OnUnEquippedItem() get called when the signal in Equipment.gd gets emitted. We set up listening to the signals when we create our hero and give it an Equipment object.

extends Node

var inventory:Array[Node] = []
var combatants:Array[Node] = []
var _random = RandomNumberGenerator.new()

func _ready():
	_random.randomize()
	CreateItems()
	CreateCombatants()
	await SimulateBattle()

func OnEquippedItem(eq:Equipment, item:Equippable):
	inventory.erase(item.get_parent())
	var message:String = "{0} equipped {1}".format([eq.get_parent().name, item.get_parent().name])
	print(message)

func OnUnEquippedItem(eq:Equipment, item:Equippable):
	inventory.append(item.get_parent())
	var message:String = "{0} un-equipped {1}".format([eq.get_parent().name, item.get_parent().name])
	print(message)

func CreateItem(title:String, type:StatTypes.Stat, amount:int):
	var item:Node = Node.new()
	item.name = title
	var smf:StatModifierFeature = StatModifierFeature.new()
	smf.name = "SMF"
	smf.type = type
	smf.amount = amount
	item.add_child(smf)
	return item

func CreateConsumableItem(title:String, type:StatTypes.Stat, amount:int):
	var item:Node = CreateItem(title,type,amount)
	var consumable:Consumable = Consumable.new()
	consumable.name = "Consumable"
	item.add_child(consumable)
	return item

func CreateEquippableItem(title:String, type:StatTypes.Stat, amount:int, slot:EquipSlots.Slot):
	var item:Node = CreateItem(title, type, amount)
	var equip:Equippable = Equippable.new()
	equip.name = "Equippable"
	equip.defaultSlots = slot
	item.add_child(equip)
	return item

func CreateHero():
	var actor:Node = CreateActor("Hero")
	var equipment = Equipment.new()
	equipment.name = "Equipment"
	equipment.EquippedNotification.connect(OnEquippedItem)
	equipment.UnEquippedNotification.connect(OnUnEquippedItem)
	actor.add_child(equipment)
	return actor
	
func CreateActor(title:String):
	var actor:Node = Node.new()
	actor.name = title
	self.add_child(actor)
	var s:Stats = Stats.new()
	s.name = "Stats"
	actor.add_child(s)
	s.SetStat(StatTypes.Stat.MHP, _random.randi_range(500, 1000))
	s.SetStat(StatTypes.Stat.HP, s.GetStat(StatTypes.Stat.MHP))
	s.SetStat(StatTypes.Stat.ATK, _random.randi_range(30, 50))
	s.SetStat(StatTypes.Stat.DEF, _random.randi_range(30, 50))
	return actor
	

func CreateItems():
	inventory.append(CreateConsumableItem("Health Potion",StatTypes.Stat.HP, 300))
	inventory.append(CreateConsumableItem("Bomb",StatTypes.Stat.HP, -150))
	inventory.append(CreateEquippableItem("Sword", StatTypes.Stat.ATK, 10, EquipSlots.Slot.PRIMARY))
	inventory.append(CreateEquippableItem("Broad Sword", StatTypes.Stat.ATK, 15, EquipSlots.Slot.PRIMARY | EquipSlots.Slot.SECONDARY))
	inventory.append(CreateEquippableItem("Shield", StatTypes.Stat.DEF, 10, EquipSlots.Slot.SECONDARY))

func CreateCombatants():
	combatants.append(CreateHero())
	combatants.append(CreateActor("Monster"))

func SimulateBattle():
	while(VictoryCheck() == false):
		LogCombatants()
		HeroTurn()
		EnemyTurn()
		var time_in_seconds = 1
		await get_tree().create_timer(time_in_seconds).timeout
	LogCombatants()
	print("Battle Completed")

func HeroTurn():
	var rnd:int = _random.randi_range(0, 1)
	match(rnd):
		0:
			Attack(combatants[0], combatants[1])
		_:
			UseInventory()

func EnemyTurn():
	Attack(combatants[1],combatants[0])

func Attack(attacker:Node, defender:Node):
	var s1:Stats
	var s2:Stats
	
	for child in attacker.get_children():
		if child is Stats:
			s1 = child
	
	for child in defender.get_children():
		if child is Stats:
			s2 = child
	
	var damage:int = floori((s1.GetStat(StatTypes.Stat.ATK) * 4 - s2.GetStat(StatTypes.Stat.DEF) * 2) * _random.randf_range(0.9,1.1))
	s2.SetStat(StatTypes.Stat.HP, s2.GetStat(StatTypes.Stat.HP) - damage)
	var message:String = "{0} hits {1} for {2} damage!".format([attacker.name, defender.name, damage])
	print(message)

func UseInventory():
	if inventory.size() == 0:
		print("No Inventory")
		return
	var rnd:int = _random.randi_range(0,inventory.size()-1)
	var item:Node = inventory[rnd]
	for child in item.get_children():
		if child is Consumable:
			ConsumeItem(item)
		if child is Equippable:
			EquipItem(item)
	
func ConsumeItem(item:Node):
	inventory.erase(item)
	
	var smf:StatModifierFeature
	var consumable:Consumable
	
	for child in item.get_children():
		if child is StatModifierFeature:
			smf = child
		elif child is Consumable:
			consumable = child
	
	if smf.amount > 0:
		consumable.Consume(combatants[0])
		print("Ah... a potion!")
		
	else:
		consumable.Consume(combatants[1])
		print("Take this you stupid monster!")
		
func EquipItem(item:Node):
	print("Perhaps this will help...")
	var toEquip:Equippable
	for child in item.get_children():
		if child is Equippable:
			toEquip = child
	var equipment:Equipment
	for child in combatants[0].get_children():
		if child is Equipment:
			equipment = child
	equipment.Equip(toEquip,toEquip.defaultSlots)
	
func VictoryCheck():
	for combatant in combatants:
		for child in combatant.get_children():
			if child is Stats:
				if child.GetStat(StatTypes.Stat.HP) <= 0:
					return true
	return false	
		
func LogCombatants():
	print("============")
	for combatant in combatants:
		LogToConsole(combatant)
	print("============")
	
func LogToConsole(actor:Node):
	var s:Stats
	for child in actor.get_children():
		if child is Stats:
			s = child
	var message:String = "Name:{0} HP:{1}/{2} ATK:{3} DEF:{4}".format([actor.name,s.GetStat(StatTypes.Stat.HP),s.GetStat(StatTypes.Stat.MHP),s.GetStat(StatTypes.Stat.ATK),s.GetStat(StatTypes.Stat.DEF)])
	print(message)

Lets hit play and watch the battle unfold in the output window. The hero and monster each take turns performing an action. The hero can attack, use an item or equip one. The monster on the other hand will only attack on their turn. After each turn it will print out that stats for each character.

You should see the Attack and Defense stats of the hero change. In this example the hero can equip any single item, or it may equip both the sword and shield at the same time giving it +10 to both ATK and DEF from the initial stats. The broad sword uses both slots so the shield should not be able to be equipped at the same time. The Broad Sword gives +15 so the max ATK stat of the hero shouldn’t be more than the starting value plus that.

Once you’ve confirmed everything is working, you can delete or detach the test object.

Summary

As always the code from the lesson is available in the repository. We built up a lot of pieces that will be useful for our final game here. The ability to create potions and items to equip will certainly come in handy.

10 thoughts on “Godot Tactics RPG – 10. Items and Equipment

  1. If you’re getting
    “(INT_AS_ENUM_WITHOUT_CAST): Integer used when an enum value is expected. If this is intended cast the integer to the enum type.gdscript(30)
    (INT_AS_ENUM_WITHOUT_MATCH): Cannot pass 3 as Enum “EquipSlots.Slot”: no enum member has matching value.gdscript(31)”
    In TestiItems for:
    inventory.append(CreateEquippableItem(“Broad Sword”, StatTypes.Stat.ATK, 15, EquipSlots.Slot.PRIMARY | EquipSlots.Slot.SECONDARY))

    You can avoid the warning by creating an intermediate variable.
    var combined_slots = EquipSlots.Slot.PRIMARY | EquipSlots.Slot.SECONDARY
    inventory.append(CreateEquippableItem(“Broad Sword”, StatTypes.Stat.ATK, 15, combined_slots))

    Anyway! Let’s see where this goes, excited for the next one! 😀

  2. Been going through this over the past week or so, enjoying it! Glad to find something that’s in depth and is closer to what my eventual goal is than a lot of the other tutorials I’ve seen so far.

    1. That was what drew me to the original Unity tutorial. There aren’t many good tutorials for Tactics RPG games out there. Basically just the original one here, and the paid Unity course by Code Monkey, although that one is a little more based on X-Com.

  3. With a friend we want to develop an rpg tactics game and without a doubt this guide has helped me to have an idea of ​​everything that involves developing a game, eagerly awaiting the next part.

    1. Glad the guide is helping. Sorry about the long delay. I just sent off the next lesson for review so that should be up sometime. Good luck on the game!

  4. Really enjoying this series, other tutorials really have a prototype feel that requires a lot of tweaking if I want to use in an actual project. You’ve also ended up with a very unique (imo) structure due to translating it from Unity, but I really like it. Very looking forward to seeing where I can take this on my own.

    One thing I’m slightly concerned about is right now there doesn’t appear to be a “Base Stats” to reset to, unless I’m misunderstanding. For example, If I take an attack potion but want my attack stats to reset after the battle is over. I suppose I could make it Equippable to simulate this. Curious to hear your thoughts.

    Excited for the next part regardless!

    1. Sorry about the long delay. Lot of things taking up attention lately, but should be better for a while. Next tutorial should be up soon. I just sent it off for review.

      That’s basically what you would do. As long as the modifier is attached to the character it doesn’t need to be an equippable item. The modification to the stat can then be removed when the modifier is removed.

Leave a Reply

Your email address will not be published. Required fields are marked *