Rogue C# – Inventory Revisited

I’m eager to introduce monsters and battles into our roguelike game for the challenge, both in play and in coding. Before we can do that, our player needs a few items in inventory, specifically:

  • Some food
  • Some armor to be worn
  • Two weapons – 1 melee and one ranged
  • Some arrows for the ranged weapon.

Adding these items will also help to test and grow the inventory system. The inventory in a roguelike is large and varied and every item has its own particular quirks to be addressed.

This post is part of a series on creating a roguelike game in C#. For the latest chapters and more information, please visit the Official Project Page on ComeauSoftware.com. The current code for this project is also available on Github.

Christmas in the Dungeon

The first task is to add the new items list of templates in the Inventory class.

The integer shown between the inventory category and the item name is the PriorityID property which I’ll be using from this point on to order the items on the inventory display screen. Previously, I was using the inventory category itself but the order of the items kept changing so it’s best to have an actual number. The values in this property will not be consistent; as more templates are added, the value can change to indicate their relative place on the list. Ideally, the values should be unique but it’s won’t break the code if some of them are the same. LINQ will simply choose one of the items to list first.

I’m still reviewing my use of delegate functions to handle inventory behavior. Armor does not need a delegate since it doesn’t actually do anything until the player is in a fight and then its rating is simply factored into damage taken from attacks. Until then, the armor the player is currently wearing can be recorded in the Player.Armor property.

/// <summary>
/// Armor currently worn
/// </summary>
public Inventory? Armor { get; set; } 

Some nice Class 3 studded leather armor will do for the start of the player’s quest.

Weapons such as swords and daggers generally don’t need delegate functions either but some are more effective when thrown than when wielded; some are less effective. According to the Rogue Vade-Mecum, which I’m using as a reference on the original game, there is a different dice roll used for throwing and wielding which would mean another pair of minimum and maximum damage properties but I don’t want to do that so I’m going to add one new Inventory class property, ThrowingBonus, which can be positive or negative.

/// <summary>
/// Positive or negative bonus for weapon when thrown rather than wielded.
/// </summary>
public int ThrowingBonus { get; set; }

Inventory.InventoryDisplay() needed an update to properly show the inventory screen and to order the items by the PriorityID property.

// Order new list by item category.
lines = lines.OrderBy(x => x.InvItem.PriorityId).ToList();

When the Player object is created at the start of the game, I want to add these new items to its inventory. I can’t use the PriorityID because that’s likely to change as more templates are added so I want to select items by their name. For this, I’m adding an overload to the GetInventoryItem() function. A new constant also puts a limit on the number of arrows or other ammunition items to allow in a batch.

public static Inventory? GetInventoryItem(string ItemName)
{
    return (from Inventory item in InventoryItems
                where item.RealName == ItemName
                select item).First();
}

public const int MAX_AMMO_BATCH = 15;

There is a chance that the name might not return an existing template so, for safety, the return object is nullable. Then, the Player class constructor will need to remove any null items it finds in the player’s inventory.

// Add inventory items
this.PlayerInventory.Add(Inventory.GetInventoryItem("some food")!);
this.PlayerInventory.Add(Inventory.GetInventoryItem("studded leather armor")!);
this.PlayerInventory.Add(Inventory.GetInventoryItem("a mace")!);
this.PlayerInventory.Add(Inventory.GetInventoryItem("a short bow")!);

// Add batch of arrows
for (int i = 1; i <= rand.Next(1, Inventory.MAX_AMMO_BATCH + 1); i++)
    this.PlayerInventory.Add(Inventory.GetInventoryItem("an arrow")!);

// Check for null items in list and remove
this.PlayerInventory = this.PlayerInventory.Where(x => x != null).ToList();

That should do it.

It’s all about organization …

There’s now a problem because there can be up to 15 arrows or crossbow bolts in a batch which will take up significant room in the player’s inventory which is limited to 50 separate items. To fix this, I decided to limit the inventory based on the number of inventory slots instead which we can do by calling the Inventory.InventoryDisplay() function from the Game.AddInventory() method and changing the INVENTORY_LIMIT constant.

public const int INVENTORY_LIMIT = 20;

// For everything else, pick it up if it can fit in inventory.
if(Inventory.InventoryDisplay(CurrentPlayer.PlayerInventory).Count 
     < Player.INVENTORY_LIMIT)

Now, any groupable items such as mangoes or arrows will take up one slot and the player can accumulate as many in each slot as they want. Each outfit of armor and most weapons take up a single slot and are not groupable so if all 20 slots are occupied and the player tries to pick up some armor, they won’t be able to until they clear a slot.

Yes, we still need more inventory templates but at least all that armor will keep the player warm in those cold dungeons.

If this gets too permissive, I can always combine the raw number of inventory items with the slot count above so that it’s 20 slots or 100 items or something like that. Once monsters are introduced however, the game should be challenging enough that the player will be eager to use whatever items they have to stay alive.

That’s not just any crossbow!

Many items have general, accuracy or damage enhancements recorded in the Increment properties and these should be displayed as part of the description. This can be done in Inventory.ListingDescription(). In the classic game, they are put in front of the item name, i.e. “+1 +0 short bow”, but I decided to put them in parentheses afterward which is a bit simpler. After the ListingDescription() function comes up with that name, some extra code adds the increments. Different categories use different increments so I’m using a Switch statement.

if (Item.IsIdentified)
{
    // Add increments if there are any.
    switch (Item.ItemCategory)
    {
        case InvCategory.Ring:
            if (Item.Increment != 0) increments = 
                Item.Increment.ToString("+0;-#");
            break;
        case InvCategory.Armor:
            increments += $"class {Item.ArmorClass} ";
            if (Item.Increment != 0) increments += 
                Item.Increment.ToString("+0;-#");
            break;
        case InvCategory.Wand:
        case InvCategory.Staff:
        case InvCategory.Weapon:
        case InvCategory.Ammunition:
            if (Item.Increment != 0) increments = 
                Item.Increment.ToString("+0;-#") + " ";
            increments += Item.AccIncrement.ToString("+0;-#") + " ";
            increments += Item.DmgIncrement.ToString("+0;-#");
            break;
        default:
            break;
    }
}

if (increments.Length > 0)
{
    increments = increments.TrimEnd();
    increments = $" ({increments})";
    retValue += increments;
}

The .ToString(“+0;-#”) syntax ensures that the sign will be shown whether its positive or negative.

The only weakness with this is that InventoryDisplay() only groups on the item name meaning if there are two items named “arrow” and one is +1 +1 and the other is +1 +0, it’s going to group them in the same slot and the Increments shown will be the ones for the next item in the slot.

Another method would be to have InventoryDisplay() group by the PriorityID property which should be unique but isn’t guaranteed to be. Of course, if it isn’t, then that’s a bug for the programmer to fix. I can also shorten InventoryDisplay() because it doesn’t need to create separate groups for identified and non-identified items now that it’s no longer grouping by name.

// Get groupable inventory.
var groupedInventory =
    (from invEntry in PlayerInventory
        where invEntry.IsGroupable
        group invEntry by invEntry.PriorityId into itemGroup
        select itemGroup).ToList();

// Get non-groupable inventory.
var individualItems =
    (from invEntry in PlayerInventory
        where !invEntry.IsGroupable
        select invEntry).ToList();

// Create a unique list of grouped items and count of each.
foreach (var itemGroup in groupedInventory)
    lines.Add(new InventoryLine { Count = itemGroup.Count(), 
        InvItem = itemGroup.First() });

// Add non-grouped items.
foreach (var invEntry in individualItems)
    lines.Add(new InventoryLine { Count = 1, InvItem = invEntry });

// Order new list by item category.
lines = lines.OrderBy(x => x.InvItem.PriorityId).ToList();

// Call the ListingDescription function to get a finished description.
foreach (InventoryLine line in lines)
{
    line.ID = charID;
    line.Description = line.ID + ".) " 
        + ListingDescription(line.Count, line.InvItem);
    charID++;
}

With this change, I can add different inventory templates with the same name but different enhancements and still separate them as long as the PriorityID is unique.

“I’ll take a dozen.”

Ammunition like arrows and crossbow bolts is a little bit of a problem because it’s supposed to come in batches. The player doesn’t usually pick up just one arrow; they pick up 6 or 14 and then they use one at a time. In this version of the game, if I drop arrows, I probably want to drop the entire batch so I can free up an inventory slot. If the player throws an arrow from their collection and misses the target, the arrow needs to go back on the map and, when they pick it up again, it needs to show as one arrow and get added back to the collection.

I thought about having the map randomly generate the number of arrows when the player picks one up but that means if they throw one, it might turn into five when they retrieve it. There was also the option of creating multiple templates with different amounts of arrows but I don’t like taking the random element out of the game and it seemed redundant.

So, let’s go through the process and take this step by step. First, I’m going to add a new property to the Inventory class with a default value of 1.

/// <summary>
/// How many items are there in the batch?
/// </summary>
public int Amount { get; set; } = 1;

Ammunition is placed on the map by the MapLevel.RoomGeneration() method so I’ll make some adjustments to have it decide the number of arrows in the batch.

// Place the inventory according to its chances of showing up.
if (rand.Next(1, 101) <= invItem.AppearancePct)
{
    // For ammunition, decide how many items are in the batch.
    if (invItem.ItemCategory == Inventory.InvCategory.Ammunition 
        && invItem.IsGroupable)
        invItem.Amount = rand.Next(1, Inventory.MAX_AMMO_BATCH + 1);

The next step happens when the player picks up the item. The Game.AddInventory() function needed a lot of changes already once the Amulet of Yendor was added to inventory. I took the opportunity to do that as shown in the next section and updated AddInventory() with all the necessary changes, including a loop that will add the new inventory item to the player’s inventory as many times as its Amount property requires.

// When the item is actually added, it needs to be a single item.
itemAmount = foundItem.Amount;
foundItem.Amount = 1;

// Move the item to the player's inventory.
for (int i = 1; i <= itemAmount; i++)
    CurrentPlayer.PlayerInventory.Add
        (Inventory.GetInventoryItem(foundItem.RealName)!);

I don’t have the code for throwing an item yet so the last step is to be able to drop the batch of items in Game.DropInventory(). The function pulls a list of all the inventory items in the selected inventory slot.

if (items[0].InvItem.ItemCategory == Inventory.InvCategory.Ammunition
    && items[0].InvItem.IsGroupable)
{
    // We're dropping the entire batch so update the amount.
    items[0].InvItem.Amount = items[0].Count;

    // For ammunition, remove all items from the slot.
    CurrentPlayer.PlayerInventory =
        CurrentPlayer.PlayerInventory
            .Where(x => x.RealName != items[0].InvItem.RealName).ToList();
                                                        
    cStatus = $"You dropped 
        {Inventory.ListingDescription(items[0].Count, items[0].InvItem)}.";

I’m allowing for the possibility of ammunition that isn’t grouped which is why it tests for both conditions. Now, the code will update the amount on the inventory object before it drops it to the map so when the player picks it up again, it will once again create the right number in their inventory.

The Amulet

You might have noticed in an earlier screenshot that the Amulet of Yendor has been added to the inventory templates. It has an ApperancePct probability of 0 since it only needs to show up once on the bottom level of the dungeon so it will still be manually placed.

public MapSpace AddAmuletToMap()
{
    MapSpace select;

    List<MapSpace> spaces = FindOpenSpaces(false);
    select = spaces[rand.Next(0, spaces.Count)];
    select.MapInventory = Inventory.GetInventoryItem("The Amulet");
    return select;
}

This also changes the Amulet from a symbol that was inserted into the MapSpace.ItemCharacter property to an actual inventory item that goes in the MapSpace.MapInventory property. Its discovery used to take up half the Game.AddInventory() function but now it’s just a little blurb at the end to update a couple of properties.

// If the player found the Amulet ...
if (foundItem.DisplayCharacter == MapLevel.AMULET)
{
    CurrentPlayer.HasAmulet = true;
    retValue = 
        "You found the Amulet of Yendor!  It has been added to your inventory.";
}