Lesson 3.3: Build Inventory System

Starting with a simple inventory item list was a good way to being thinking about this problem and building the user experience to show a player’s inventory. However, exposing the list directly and allowing all callers to edit the list can lead to a lot of duplicate code editing that list, and builds more interdependencies than we may like. To help encapsulate more of the Inventory logic, we are going to build a more robust Inventory class that manages these interactions.

Tweaks to GameItem and Weapon

Our game items are currently all unique and listed separately in our inventory. The first thing we need to realize is that there are unique items and regular items in our game. Unique items should be treated differently, like not grouping in the inventory. For common items we don’t care as much about the individual item so they can get grouped… no cares about the individuality of 20 rat skins.

To enable these changes, we’re going to update GameItem to include an IsUnique property:

namespace SimpleRPG.Game.Engine.Models
{
    public class GameItem
    {
        public static readonly GameItem Empty = new GameItem();

        public GameItem(int itemTypeID, string name, int price, bool isUnique = false)
        {
            ItemTypeID = itemTypeID;
            Name = name;
            Price = price;
            IsUnique = isUnique;
        }

        public GameItem()
        {
        }

        public int ItemTypeID { get; set; }

        public string Name { get; set; } = string.Empty;

        public int Price { get; set; }

        public bool IsUnique { get; set; }

        public virtual GameItem Clone() =>
            new GameItem(ItemTypeID, Name, Price, IsUnique);
    }
}

Notice that we updated the constructor to have an isUnique parameter, and it defaults to false if it’s not specified. For general-purpose game items, they are not unique.

Then, we update the Weapon code to the following (since it derives from GameItem):

namespace SimpleRPG.Game.Engine.Models
{
    public class Weapon : GameItem
    {
        public Weapon(int itemTypeID, string name, int price, int minDamage, int maxDamage)
            : base(itemTypeID, name, price, true)
        {
            MinimumDamage = minDamage;
            MaximumDamage = maxDamage;
        }

        public Weapon()
        {
        }

        public int MinimumDamage { get; set; }

        public int MaximumDamage { get; set; }

        public override GameItem Clone() =>
            new Weapon(ItemTypeID, Name, Price, MinimumDamage, MaximumDamage);
    }
}

In this code, we don’t change the Weapon constructor and always call the GameItem base constructor with isUnique = true. We want our weapons to be treated like unique items in the game.

Adding Grouped Inventory Items

In our current inventory list, each item gets added as its own entry. And in response, our UI shows each item in its own row. We are going to change that by grouping common items together and keeping a count of each item.

Let’s enable that by creating the new GroupedInventoryItem class in the SimpleRPG.Game.Engine project and Models folder.

namespace SimpleRPG.Game.Engine.Models
{
    public class GroupedInventoryItem
    {
        public GameItem Item { get; set; } = GameItem.Empty;

        public int Quantity { get; set; }
    }
}

This is a pretty simple class that contains a GameItem and the Quantity for each. We will use this class as the basis for grouping in our inventory.

Notice that we add an implementation of GameItem.Empty. This is a static property that simplifies us always needing to create an empty instance of GameItem. We can see examples of this pattern in the .NET Core framework in classes like String and Guid.

The Inventory Class

Now we are going to implement a new class that encapsulates the inventory functionality. It will support the following initial features:

  • Grouping by item and enabling quantities.
  • Adding an item to the inventory (and automatically grouping as needed).
  • Removing an item from the inventory (and automatically decrementing or deleting grouped items).
  • Read-only access to the lists for all items and grouped items. It’s read-only because it is necessary to show in the UI, but we don’t want callers directly adding to that list.

Let’s create a new Inventory class in the SimpleRPG.Game.Engine project and Models folder:

using System;
using System.Collections.Generic;
using System.Linq;

namespace SimpleRPG.Game.Engine.Models
{
    public class Inventory
    {
        private readonly List<GameItem> _backingInventory = new List<GameItem>();
        private readonly List<GroupedInventoryItem> _backingGroupedInventory = new List<GroupedInventoryItem>();

        public Inventory(IEnumerable<GameItem> items)
        {
            if (items == null)
            {
                return;
            }

            foreach (GameItem item in items)
            {
                AddItem(item);
            }
        }

        public Inventory()
        {
        }

        public IReadOnlyList<GameItem> Items => _backingInventory.AsReadOnly();

        public IReadOnlyList<GroupedInventoryItem> GroupedItems => _backingGroupedInventory.AsReadOnly();

        public void AddItem(GameItem item)
        {
            _ = item ?? throw new ArgumentNullException(nameof(item));

            _backingInventory.Add(item);

            if (item.IsUnique)
            {
                _backingGroupedInventory.Add(new GroupedInventoryItem { Item = item, Quantity = 1 });
            }
            else
            {
                if (_backingGroupedInventory.All(gi => gi.Item.ItemTypeID != item.ItemTypeID))
                {
                    _backingGroupedInventory.Add(new GroupedInventoryItem { Item = item, Quantity = 0 });
                }

                _backingGroupedInventory.First(gi => gi.Item.ItemTypeID == item.ItemTypeID).Quantity++;
            }
        }

        public void RemoveItem(GameItem item)
        {
            _ = item ?? throw new ArgumentNullException(nameof(item));

            _backingInventory.Remove(item);

            GroupedInventoryItem groupedInventoryItemToRemove =
                _backingGroupedInventory.FirstOrDefault(gi => gi.Item == item);

            if (groupedInventoryItemToRemove != null)
            {
                if (groupedInventoryItemToRemove.Quantity == 1)
                {
                    _backingGroupedInventory.Remove(groupedInventoryItemToRemove);
                }
                else
                {
                    groupedInventoryItemToRemove.Quantity--;
                }
            }
        }
    }
}

Looking at the individual code by functionality:

  • Lines 9 & 10 define two lists for internally tracking the raw item list and the grouped list.
  • Lines 29 & 31 define the read-only lists that callers can access to see what is in the Inventory.
  • The AddItem method (lines 33-52) performs the following operations:
    • Always adds the item to the base item list.
    • If the item is unique, it also adds a single item to the grouped list.
    • Finally if it’s a common item, it increments the quantity of the item in the grouped list. If it does not exist in the grouped list, then it adds the item to the grouped list as the first item.
  • The RemoveItem method (lines 54-74) does the inverse of the previous code:
    • Removes item from the base item list.
    • If the item in the grouped list has larger than 1 quantity, then it decrements the quantity.
    • If the item only has a quantity of 1, then it removed the item from the grouped list.

With this more complex logic, we can see why we want to encapsulate this behavior in its own class, so that no other components need to know anything about this behavior. They only need to be concerned about adding items, removing items, and getting a snapshot of the current inventory list.

Note: I’ve also included extensive unit tests for these Inventory operations. Please review the unit tests associated with this commit, if you want to learn more about how this was tested.

Integrating Inventory

Now that we have a more robust inventory system, we need to update our existing game code to use it. It’s not a lot of changes, but it is across three different classes.

1. We update the Player class to replace the IList<GameItem> property with the Inventory class:

using System.Collections;
using System.Collections.Generic;

namespace SimpleRPG.Game.Engine.Models
{
    public class Player
    {
        public string Name { get; set; } = string.Empty;

        public string CharacterClass { get; set; } = string.Empty;

        public int HitPoints { get; set; }

        public int ExperiencePoints { get; set; }

        public int Level { get; set; }
        
        public int Gold { get; set; }

        public Inventory Inventory { get; } = new Inventory();
    }
}

2. We update the PlayerTabs component rendering to add a Quantity column, iterate over the Inventory.GroupedItems list (because we want a more compact inventory list), and then show the Quantity for each grouped item.

<Tabs SelectedTab="@_selectedTab" Pills="true" SelectedTabChanged="OnSelectedTabChanged">
    <Items>
        <Tab Name="inventory">Inventory</Tab>
        <Tab Name="quests">Quests</Tab>
    </Items>
    <Content>
        <TabPanel Name="inventory">
            <div class="table-wrapper-scroll-y my-custom-scrollbar">
                <Table Bordered="true" Hoverable="true" Narrow="true" Striped="true"
                       Style="background-color: white">
                    <TableHeader>
                        <TableRowCell>Name</TableRowCell>
                        <TableRowCell>Qty</TableRowCell>
                        <TableRowCell>Price</TableRowCell>
                    </TableHeader>
                    <TableBody>
                        @foreach (var groupedItem in Player.Inventory.GroupedItems)
                        {
                        <TableRow>
                            <TableRowCell>@groupedItem.Item.Name</TableRowCell>
                            <TableRowCell>@groupedItem.Quantity</TableRowCell>
                            <TableRowCell>@groupedItem.Item.Price</TableRowCell>
                        </TableRow>
                        }
                    </TableBody>
                </Table>
            </div>
        </TabPanel>
        <TabPanel Name="quests">
            coming soon...
        </TabPanel>
    </Content>
</Tabs>

@code {
    private string _selectedTab = "inventory";

    [Parameter]
    public Player Player { get; set; } = new Player();

    public void OnSelectedTabChanged(string newTab)
    {
        _selectedTab = newTab;
    }
}

3. We update the GameSession to add the first weapon to the player’s inventory:

using SimpleRPG.Game.Engine.Factories;
using SimpleRPG.Game.Engine.Models;

namespace SimpleRPG.Game.Engine.ViewModels
{
    public class GameSession : IGameSession
    {
        private readonly World _currentWorld;

        public Player CurrentPlayer { get; private set; }

        public Location CurrentLocation { get; private set; }

        public MovementUnit Movement { get; private set; }

        public GameSession()
        {
            CurrentPlayer = new Player
            {
                Name = "DarthPedro",
                CharacterClass = "Fighter",
                HitPoints = 10,
                Gold = 1000,
                ExperiencePoints = 0,
                Level = 1
            };

            _currentWorld = WorldFactory.CreateWorld();

            Movement = new MovementUnit(_currentWorld);
            CurrentLocation = Movement.CurrentLocation;

            CurrentPlayer.Inventory.AddItem(ItemFactory.CreateGameItem(1001));
        }

        public void OnLocationChanged(Location newLocation) =>
            CurrentLocation = newLocation;
    }
}

We can build the game and run it again. We don’t see a huge change in the game at the moment, but the Inventory tab should show a table with 3 columns now (Name, Qty, Price).

Fig 1 – Game screen with new Inventory class

In this lesson, we went through a change of the inventory system to build a more robust implementation. It gives our game more capabilities and sets us up for easy Inventory integration in the coming lessons with monsters, combat, and traders.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s