Inventory system design and development

This tutorial describes the design and development of an inventory system for a dungeon crawler game. An object-oriented paradigm is employed, with a UML class diagram created using the draw.io tool, used to identify the main objects and their relationships. This class diagram will be used as a framework for the development of code.

For this initial iteration of the inventory design, the design consists of four classes. All item objects (represented by the Item class) include name and weight attributes. Two specialisations of the Item class are defined using inheritance. The first of these is the Weapon subclass which adds two additional attributes representing the minimum and maximum amounts of damage done by the weapon. The Armour subclass has one additional attribute that is used to represent the level of protection provided by the armour.

The Inventory class is used to keep track of all items carried by a player character. The directed link from the Inventory class to the Item class indicates that each inventory contains 0 to many items. Attributes in the class are used to represent items that are currently equipped (in use) by the player character. Methods are included that enable items to be added and removed from the inventory. The total_weight method calculates the combined weight of all items in the inventory – this can be used to determine whether a character is encumbered. The last three methods shown are used to equip items.

Item class

The Item class contains an initialiser method for creating new instances. The name of the item and its weight is passed as arguments to the initialiser method, these are then used to set the name and weight attributes. A third attribute, bonus, is used to represent a (typically magical) bonus that improves the capabilites of the item (e.g. a weapon would deal more damage, while armour would provide more protection).

As shown in the class diagram, two subclasses of the Item class are created. Subclasses inherit the attributes and methods of its parent class, but may include additional attributes and/or method, or may indeed override (re-implement) methods of the parent class. In our example the Armour and Weapon subclasses are introduced. They both have additional attributes that make the subclass as specialisation of its parent. Armour includes an additional attribute, armour_class, which models the amount of protection that the armour provides. The Weapon class includes attributes representing the minimum and maximum amount of raw damage done by the weapon.

Both of the subclasses override the initialiser method of the parent class. The initialiser method for the Armour class includes an additional argument, ac, used to the armour_class attribute. The initialiser of the subclasses also calls the initialiser method the parent class using the super method, as shown on lines 10 and 16 of the code listing below.

class Item:
    def __init__(self, name, weight):
        self.name = name
        self.weight = weight
        self.bonus = 0

class Armour(Item):
    def __init__(self, name, weight, ac):
        super().__init__(name, weight)
        self.armour_class = ac

class Weapon(Item):
    def __init__(self, name, weight, min_damage, max_damage):
        super().__init__(name, weight)
        self.min_damage = min_damage
        self.max_damage = max_damage

Inventory class

The Inventory class keeps track of the items a player character has in their possession. The collection of items is stored in a list, which is initially empty. Additional attributes are used to model the items that are currently equipped by the player character. For this initial design, items held in the right and left hands, together with any armour worn are modelled.

A method for adding items to the inventory is defined next, followed by a method that is called to remove an item from the inventory. For the latter method a check is done to ensure that the item is in the inventory before it is dropped.

class Inventory:
    def __init__(self):
        self.contents = []
        self.right_arm = None
        self.left_arm = None
        self.armour = None

    def add_item(self, item):
        self.contents.append(item)

    def drop_item(self, item):
        if item in self.contents:
            self.contents.remove(item)

The following method calculates the total weight of the items in the inventory. This is done using a list comprehension to collect all of the weights into a list. The sum function is then applied to this to find the total weight.

    def total_weight(self):
        return sum([i.weight for i in self.contents])

The following method is used to equip a weapon in the right hand of the player character. A check if done to ensure that the item is in the inventory contents and that the item is a weapon. The method returns a Boolean-valued result indicating whether or not the method was successful.

    def equip_right_arm(self, item):
        if item in self.contents and type(item).__name__ == "Weapon":
            self.right_arm = item
            return True
        else:
            return False

The following method creates a string representation of the inventory. The equipped items are shown first, using an inline if statement to check whether or not the equipment slots contain an item. A for loop is then used to represent the complete contents of the inventory.

    def __str__(self):
        result = "Equipped items\n"
        result += "---------------\n"
        result += "Right Arm ::" + (self.right_arm.name if self.right_arm else "empty") + "\n"
        result += "Left Arm ::" + (self.left_arm.name if self.left_arm else "empty") + "\n"
        result += "Armour ::" + (self.armour.name if self.armour else "empty") + "\n"
        result += "\nOther items\n"
        result += "---------------\n"
        for i in self.contents:
            result += i.name + "\n"
        return result

Driver Code

The driver code shown below is used to test the correctness of the previous code. Instances of armour and weapon classes are created. A new inventory object is created with the item instances added to this inventory. The contents of the inventory is then printed using the string representation method which is called implicitly when the inventory object is passed as the print argument. Further tests are done to check the correctness of the other methods from the inventory class.

from item import *
from inventory import *

armour1 = Armour("Plate Armour", 120, 18)
armour2 = Armour("Leather Armour", 20, 12)

weapon1 = Weapon("Longsword", 15, 1, 8)
weapon2 = Weapon("Axe", 8, 1, 6)

# create new inventory and add items
inventory = Inventory()

inventory.add_item(armour1)
inventory.add_item(armour2)
inventory.add_item(weapon1)
inventory.add_item(weapon2)

print(inventory)

# remove item from inventory

inventory.drop_item(armour2)
print(inventory)

# check the total weight of the inventory
print(inventory.total_weight())

# equip a weapon with right arm
inventory.equip_right_arm(weapon1)
print(inventory)

# try equipping right arm with armour
inventory.equip_right_arm(armour1)

print(inventory)

Designing a turn-based combat controller

The following tutorial explains how to design a turn-based combat system for a dungeon crawler game using a finite state machine. This forms part of a bigger project aimed at developing a 2D top-down dungeon crawler using pygame. The finite state machine shows the different states that are possible during combat and the transitions that can be made between these states.

For this example we limit the design to a single player character completing a single turn of combat. The character can move and make melee or ranged attack actions.

Finite state machine

During each turn of combat player character can move and perform an action. Movement can occur before and/or after the action, provided the total movement does not exceed the character’s move distance. Because the screen needs to refresh each frame we need to also keep track other other states between movement and action. For example, when the player character elects to move, the combat controller needs to transition through several states to complete the move. This includes states for:

  • Initiating movement
  • Selecting the target location
  • Moving the character to the target location

These states are shown at the top of the following finite state machine diagram. States are shown as circles, with a title/description for each state. Transitions between the states
are shown as arcs (directed lines) that connect the states. These arcs are labelled with conditions that must be met for the transition to occur. For example, the combat controller will transition from the “pre move location” state to the “pre move” state when a valid target location is selected. If no selection is made, or the selection is invalid (e.g. too far away) then the combat controller remains in the same state.

To explain this further, we look demonstrate two main parts of the combat controller (movement and ranged attacks) in more detail using examples from the final game.

Pre action movement

In the video below, the selected player character begins in the “move or action state”. The player selects the move options (key “m”) at which point the character transitions into the “pre move location” state. In this state the player is able to select the target location that the character moves to. This is done by moving the crosshairs using mouse controls. The player then clicks on the target location – if this is a valid location then the character transitions to the “pre move” state. In this state the movement of the character occurs. When the target location is reached, the character transitions into either the “melee attack” or “ranged target” states.

Ranged attacks

A ranged attack is initiated when the player enters “r” either at the beginning of their turn, or after they have moved, at this point the character transitions to the “ranged target” state.
In this state the player moves the crosshairs to the target of their ranged attack. When they have selected a valid target and pressed the mouse button, the character transitions to the “ranged shoot” state. In this state the arrow travels towards the target enemy, moving a small distance each frame. When the arrow reaches the enemy, the character transitions to the “ranged attack” state. At this point it is determined whether the arrow hit the enemy, and if so how much damage is done. The combat controller moves the “show damage” state, either indicating a miss, or a hit with the amount of damage done.