Lesson 4.4: Create the First Consumable Item

Most game players are familiar with consumable items – these are single use items that are used to provide the player an effect… like a potion of healing that lets us rejuvenate some of our hit points. This is a common concept in many roleplaying games. So we are going to introduce a Granola bar consumable item into our game that heals the player for 2 hit points of damage.

To enable this, we are going to extend our GameItem to support another type (ItemCategory.Consumable), build a new healing action, and plumb the feature all the way through to our game screen to allow the player to use the item whenever they wish to.

Minor Model Updates

In order to support consumable items, there’s just a few minor changes that we need to make to our model classes and factories:

1. Update ItemCategory enum (line #12) to include a Consumable value in the GameItem.cs file.

using SimpleRPG.Game.Engine.Actions;
using System;

namespace SimpleRPG.Game.Engine.Models
{
    public class GameItem
    {
        public enum ItemCategory
        {
            Miscellaneous,
            Weapon,
            Consumable
        }

        public static readonly GameItem Empty = new GameItem();

        public GameItem(int itemTypeID, ItemCategory category, string name, int price, bool isUnique = false, IAction? action = null)
        {
            ItemTypeID = itemTypeID;
            Category = category;
            Name = name;
            Price = price;
            IsUnique = isUnique;
            Action = action;
        }

        public GameItem()
        {
        }

        public int ItemTypeID { get; set; }
        
        public ItemCategory Category { get; set; }

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

        public int Price { get; set; }

        public bool IsUnique { get; set; }

        public IAction? Action { get; set; }

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

        public DisplayMessage PerformAction(LivingEntity actor, LivingEntity target)
        {
            if (Action is null)
            {
                throw new InvalidOperationException("CurrentWeapon.Action cannot be null");
            }

            return Action.Execute(actor, target);
        }
    }
}

2. Add Conumables property (lines #36-37) that retrieves a list of consumable items from the Inventory list (in the Inventory.cs file).

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 IList<GameItem> Weapons =>
            Items.Where(i => i.Category == GameItem.ItemCategory.Weapon).ToList();

        public List<GameItem> Consumables =>
            Items.Where(i => i.Category == GameItem.ItemCategory.Consumable).ToList();

        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);

            if (item.IsUnique == false)
            {
                GroupedInventoryItem groupedInventoryItemToRemove =
                    _backingGroupedInventory.FirstOrDefault(gi => gi.Item.ItemTypeID == item.ItemTypeID);

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

        public bool HasAllTheseItems(IEnumerable<ItemQuantity> items)
        {
            return items.All(item => Items.Count(i => i.ItemTypeID == item.ItemId) >= item.Quantity);
        }
    }
}

3. Update LivingEntity to have CurrentConsumable and HasCurrentConsumable properties (lines #23-25) that manage a creature’s selected consumable. And add the UseCurrentConsumable method (lines #47-56) that manages using that item.

using System;

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

        public int CurrentHitPoints { get; set; }

        public int MaximumHitPoints { get; set; }

        public int Gold { get; set; }

        public int Level { get; set; }

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

        public GameItem? CurrentWeapon { get; set; }

        public bool HasCurrentWeapon => CurrentWeapon != null;

        public GameItem? CurrentConsumable { get; set; }

        public bool HasCurrentConsumable => CurrentConsumable != null;

        public bool IsDead => CurrentHitPoints <= 0;

        public void TakeDamage(int hitPointsOfDamage)
        {
            if (hitPointsOfDamage > 0)
            {
                CurrentHitPoints -= hitPointsOfDamage;
            }
        }

        public DisplayMessage UseCurrentWeaponOn(LivingEntity target)
        {
            if (CurrentWeapon is null)
            {
                throw new InvalidOperationException("CurrentWeapon cannot be null.");
            }

            return CurrentWeapon.PerformAction(this, target);
        }

        public DisplayMessage UseCurrentConsumable(LivingEntity target)
        {
            if (CurrentConsumable is null)
            {
                throw new InvalidOperationException("CurrentConsumable cannot be null.");
            }

            Inventory.RemoveItem(CurrentConsumable);
            return CurrentConsumable.PerformAction(this, target);
        }

        public void Heal(int hitPointsToHeal)
        {
            if (hitPointsToHeal > 0)
            {
                CurrentHitPoints += hitPointsToHeal;

                if (CurrentHitPoints > MaximumHitPoints)
                {
                    CurrentHitPoints = MaximumHitPoints;
                }
            }
        }

        public void CompletelyHeal()
        {
            CurrentHitPoints = MaximumHitPoints;
        }

        public void ReceiveGold(int amountOfGold)
        {
            if (amountOfGold > 0)
            {
                Gold += amountOfGold;
            }
        }

        public void SpendGold(int amountOfGold)
        {
            if (amountOfGold > Gold)
            {
                throw new ArgumentOutOfRangeException(nameof(amountOfGold), $"{Name} only has {Gold} gold, and cannot spend {amountOfGold} gold");
            }

            if (amountOfGold > 0)
            {
                Gold -= amountOfGold;
            }
        }
    }
}

4. In the ItemFactory class, define the Granola bar item (line #21), and the BuildHealingItem factory method (lines #49-55) to create fully defined healing consumable items.

using SimpleRPG.Game.Engine.Actions;
using SimpleRPG.Game.Engine.Models;
using System.Collections.Generic;
using System.Linq;

namespace SimpleRPG.Game.Engine.Factories
{
    internal static class ItemFactory
    {
        private static readonly List<GameItem> _standardGameItems = new List<GameItem>();

        static ItemFactory()
        {
            BuildWeapon(1001, "Pointy Stick", 1, "1d2");
            BuildWeapon(1002, "Rusty Sword", 5, "1d3");

            BuildWeapon(1501, "Snake fangs", 0, "1d2");
            BuildWeapon(1502, "Rat claws", 0, "1d2");
            BuildWeapon(1503, "Spider fangs", 0, "1d4");

            BuildHealingItem(2001, "Granola bar", 5, 2);

            BuildMiscellaneousItem(9001, "Snake fang", 1);
            BuildMiscellaneousItem(9002, "Snakeskin", 2);
            BuildMiscellaneousItem(9003, "Rat tail", 1);
            BuildMiscellaneousItem(9004, "Rat fur", 2);
            BuildMiscellaneousItem(9005, "Spider fang", 1);
            BuildMiscellaneousItem(9006, "Spider silk", 2);
        }

        public static GameItem CreateGameItem(int itemTypeID)
        {
            var standardItem = _standardGameItems.First(i => i.ItemTypeID == itemTypeID);

            return standardItem.Clone();
        }

        private static void BuildMiscellaneousItem(int id, string name, int price) =>
            _standardGameItems.Add(new GameItem(id, GameItem.ItemCategory.Miscellaneous, name, price));

        private static void BuildWeapon(int id, string name, int price, string damageDice)
        {
            var weapon = new GameItem(id, GameItem.ItemCategory.Weapon, name, price, true);
            weapon.Action = new Attack(weapon, damageDice);

            _standardGameItems.Add(weapon);
        }

        private static void BuildHealingItem(int id, string name, int price, int hitPointsToHeal)
        {
            GameItem item = new GameItem(id, GameItem.ItemCategory.Consumable, name, price);
            item.Action = new Heal(item, hitPointsToHeal);
            _standardGameItems.Add(item);
        }
    }
}

Create the Heal Action

Just like we did with the Attack action in lesson 4.2, we are going to define a new Heal action that heals up a creature’s hit points. We will use the same IAction interface to define our action/command. And the Heal action will implement the IAction interface too.

Let’s create the Heal class in the SimpleRPG.Game.Engine project and Actions folder.

using SimpleRPG.Game.Engine.Models;
using System;

namespace SimpleRPG.Game.Engine.Actions
{
    public class Heal : IAction
    {
        private readonly GameItem _item;
        private readonly int _hitPointsToHeal;

        public Heal(GameItem item, int hitPointsToHeal)
        {
            _item = item ?? throw new ArgumentNullException(nameof(item));
            if (item.Category != GameItem.ItemCategory.Consumable)
            {
                throw new ArgumentException($"{item.Name} is not consumable");
            }

            if (hitPointsToHeal <= 0)
            {
                throw new ArgumentOutOfRangeException($"{item.Name} must have positive healing value.");
            }

            _hitPointsToHeal = hitPointsToHeal;
        }

        public DisplayMessage Execute(LivingEntity actor, LivingEntity target)
        {
            _ = actor ?? throw new ArgumentNullException(nameof(actor));
            _ = target ?? throw new ArgumentNullException(nameof(target));

            string actorName = (actor is Player) ? "You" : $"The {actor.Name.ToLower()}";
            string targetName = (target is Player) ? "yourself" : $"the {target.Name.ToLower()}";

            target.Heal(_hitPointsToHeal);

            return new DisplayMessage(
                "Heal Effect",
                $"{actorName} heal {targetName} for {_hitPointsToHeal} point{(_hitPointsToHeal > 1 ? "s" : "")}.");
        }
    }
}

Notice that this class looks very similar in structure to the Attack class: we have a constructor (that takes data about this action), and the Execute method (that performs the operation).

Our constructor validates that the item is of Consumable type and save the data about how much healing to perform.

The Execute method:

  • Validates that the actor and target are valid instances.
  • Sets up the names that will be shown in the DisplayMessage.
  • Calls the Heal method on the target LivingEntity.
  • And returns the DisplayMessage with the information about how many hit points were healed.
  • Note that when the player uses an item to heal themselves, both the actor and target are the same.

That’s all the code that is necessary for healing any living creature in the game.

View Model Changes to Support Consumable Items

Now we need to create a view model method to expose consuming an item. This method will be used by the game screen to initiate the operation in response to the player clicking a button.

1. Let’s update the IGameSession interface to include the ConsumeCurrentItem method:

using SimpleRPG.Game.Engine.Models;
using System.Collections.Generic;

namespace SimpleRPG.Game.Engine.ViewModels
{
    public interface IGameSession
    {
        Player CurrentPlayer { get; }

        Location CurrentLocation { get; }

        Monster? CurrentMonster { get; }

        bool HasMonster { get; }

        Trader? CurrentTrader { get; }

        MovementUnit Movement { get; }

        IList<DisplayMessage> Messages { get; }

        void OnLocationChanged(Location newLocation);

        void AttackCurrentMonster(GameItem? currentWeapon);

        void ConsumeCurrentItem(GameItem? item);
    }
}

2. Implement that method in the GameSession class (lines #116-127), and initialize the current player with a single consumable item (line #61).

using SimpleRPG.Game.Engine.Factories;
using SimpleRPG.Game.Engine.Models;
using SimpleRPG.Game.Engine.Services;
using System;
using System.Collections.Generic;
using System.Linq;

namespace SimpleRPG.Game.Engine.ViewModels
{
    public class GameSession : IGameSession
    {
        private readonly World _currentWorld;
        private readonly IDiceService _diceService;
        private readonly int _maximumMessagesCount = 100;

        public Player CurrentPlayer { get; private set; }

        public Location CurrentLocation { get; private set; }

        public Monster? CurrentMonster { get; private set; }

        public bool HasMonster => CurrentMonster != null;

        public Trader? CurrentTrader { get; private set; }

        public MovementUnit Movement { get; private set; }

        public IList<DisplayMessage> Messages { get; } = new List<DisplayMessage>();

        public GameSession(int maxMessageCount)
            : this()
        {
            _maximumMessagesCount = maxMessageCount;
        }

        public GameSession(IDiceService? diceService = null)
        {
            _diceService = diceService ?? DiceService.Instance;

            CurrentPlayer = new Player
            {
                Name = "DarthPedro",
                CharacterClass = "Fighter",
                CurrentHitPoints = 10,
                MaximumHitPoints = 10,
                Gold = 1000,
                Level = 1
            };

            _currentWorld = WorldFactory.CreateWorld();

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

            if (!CurrentPlayer.Inventory.Weapons.Any())
            {
                CurrentPlayer.Inventory.AddItem(ItemFactory.CreateGameItem(1001));
            }

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

        public void OnLocationChanged(Location newLocation)
        {
            _ = newLocation ?? throw new ArgumentNullException(nameof(newLocation));

            CurrentLocation = newLocation;
            Movement.UpdateLocation(CurrentLocation);
            GetMonsterAtCurrentLocation();
            CompleteQuestsAtLocation();
            GetQuestsAtLocation();
            CurrentTrader = CurrentLocation.TraderHere;
        }

        public void AttackCurrentMonster(GameItem? currentWeapon)
        {
            if (CurrentMonster is null)
            {
                return;
            }

            if (currentWeapon is null)
            {
                AddDisplayMessage("Combat Warning", "You must select a weapon, to attack.");
                return;
            }

            // player acts monster with weapon
            CurrentPlayer.CurrentWeapon = currentWeapon;
            var message = CurrentPlayer.UseCurrentWeaponOn(CurrentMonster);
            AddDisplayMessage(message);

            // if monster is killed, collect rewards and loot
            if (CurrentMonster.IsDead)
            {
                OnCurrentMonsterKilled(CurrentMonster);

                // get another monster to fight
                GetMonsterAtCurrentLocation();
            }
            else
            {
                // if monster is still alive, let the monster attack
                message = CurrentMonster.UseCurrentWeaponOn(CurrentPlayer);
                AddDisplayMessage(message);

                // if player is killed, move them back to their home and heal.
                if (CurrentPlayer.IsDead)
                {
                    OnCurrentPlayerKilled(CurrentMonster);
                }
            }
        }

        public void ConsumeCurrentItem(GameItem? item)
        {
            if (item is null || item.Category != GameItem.ItemCategory.Consumable)
            {
                AddDisplayMessage("Item Warning", "You must select a consumable item to use.");
                return;
            }

            // player uses consumable item to heal themselves and item is removed from inventory.
            CurrentPlayer.CurrentConsumable = item;
            var message = CurrentPlayer.UseCurrentConsumable(CurrentPlayer);
            AddDisplayMessage(message);
        }

        private void OnCurrentPlayerKilled(Monster currentMonster)
        {
            AddDisplayMessage("Player Defeated", $"The {currentMonster.Name} killed you.");

            CurrentPlayer.CompletelyHeal();  // Completely heal the player
            this.OnLocationChanged(_currentWorld.LocationAt(0, -1));  // Return to Player's home
        }

        private void OnCurrentMonsterKilled(Monster currentMonster)
        {
            var messageLines = new List<string>();
            messageLines.Add($"You defeated the {currentMonster.Name}!");

            CurrentPlayer.AddExperience(currentMonster.RewardExperiencePoints);
            messageLines.Add($"You receive {currentMonster.RewardExperiencePoints} experience points.");

            CurrentPlayer.ReceiveGold(currentMonster.Gold);
            messageLines.Add($"You receive {currentMonster.Gold} gold.");

            foreach (GameItem item in currentMonster.Inventory.Items)
            {
                CurrentPlayer.Inventory.AddItem(item);
                messageLines.Add($"You received {item.Name}.");
            }

            AddDisplayMessage("Monster Defeated", messageLines);
        }

        private void GetMonsterAtCurrentLocation()
        {
            CurrentMonster = CurrentLocation.HasMonster() ? CurrentLocation.GetMonster() : null;

            if (CurrentMonster != null)
            {
                AddDisplayMessage("Monster Encountered:", $"You see a {CurrentMonster.Name} here!");
            }
        }

        private void GetQuestsAtLocation()
        {
            foreach (Quest quest in CurrentLocation.QuestsAvailableHere)
            {
                if (!CurrentPlayer.Quests.Any(q => q.PlayerQuest.Id == quest.Id))
                {
                    CurrentPlayer.Quests.Add(new QuestStatus(quest));

                    var messageLines = new List<string>
                    {
                        quest.Description,
                        "Items to complete the quest:"
                    };

                    foreach (ItemQuantity q in quest.ItemsToComplete)
                    {
                        messageLines.Add($"    {ItemFactory.CreateGameItem(q.ItemId).Name} (x{q.Quantity})");
                    }

                    messageLines.Add("Rewards for quest completion:");
                    messageLines.Add($"   {quest.RewardExperiencePoints} experience points");
                    messageLines.Add($"   {quest.RewardGold} gold");
                    foreach (ItemQuantity itemQuantity in quest.RewardItems)
                    {
                        messageLines.Add($"   {itemQuantity.Quantity} {ItemFactory.CreateGameItem(itemQuantity.ItemId).Name} (x{itemQuantity.Quantity})");
                    }

                    AddDisplayMessage($"Quest Added - {quest.Name}", messageLines);
                }
            }
        }

        private void CompleteQuestsAtLocation()
        {
            foreach (Quest quest in CurrentLocation.QuestsAvailableHere)
            {
                QuestStatus questToComplete =
                    CurrentPlayer.Quests.FirstOrDefault(q => q.PlayerQuest.Id == quest.Id &&
                                                             !q.IsCompleted);

                if (questToComplete != null)
                {
                    if (CurrentPlayer.Inventory.HasAllTheseItems(quest.ItemsToComplete))
                    {
                        // Remove the quest completion items from the player's inventory
                        foreach (ItemQuantity itemQuantity in quest.ItemsToComplete)
                        {
                            for (int i = 0; i < itemQuantity.Quantity; i++)
                            {
                                CurrentPlayer.Inventory.RemoveItem(
                                    CurrentPlayer.Inventory.Items.First(
                                        item => item.ItemTypeID == itemQuantity.ItemId));
                            }
                        }

                        // give the player the quest rewards
                        var messageLines = new List<string>();
                        CurrentPlayer.AddExperience(quest.RewardExperiencePoints);
                        messageLines.Add($"You receive {quest.RewardExperiencePoints} experience points");

                        CurrentPlayer.ReceiveGold(quest.RewardGold);
                        messageLines.Add($"You receive {quest.RewardGold} gold");

                        foreach (ItemQuantity itemQuantity in quest.RewardItems)
                        {
                            GameItem rewardItem = ItemFactory.CreateGameItem(itemQuantity.ItemId);

                            CurrentPlayer.Inventory.AddItem(rewardItem);
                            messageLines.Add($"You receive a {rewardItem.Name}");
                        }

                        AddDisplayMessage($"Quest Completed - {quest.Name}", messageLines);

                        // mark the quest as completed
                        questToComplete.IsCompleted = true;
                    }
                }
            }
        }

        private void AddDisplayMessage(string title, string message) =>
            AddDisplayMessage(title, new List<string> { message });

        private void AddDisplayMessage(string title, IList<string> messages)
        {
            AddDisplayMessage(new DisplayMessage(title, messages));
        }

        private void AddDisplayMessage(DisplayMessage message)
        {
            this.Messages.Insert(0, message);

            if (Messages.Count > _maximumMessagesCount)
            {
                Messages.Remove(Messages.Last());
            }
        }
    }
}

That’s all we need in the game engine, now we can surface this functionality in the UI.

CombatComponent Changes

We already have the CombatComponent which shows the player their list of weapons and the Attack button. We are going to update this component to also allow the player to select an item from a list of consumable items, and use that item with a button click.

<Heading Size="HeadingSize.Is5" Margin="Margin.Is2.OnY">Combat</Heading>
<Row Margin="Margin.Is2.OnY">
    <Column ColumnSize="ColumnSize.Is6.Is2.WithOffset" Margin="Margin.Is1.FromTop">
        <Select id="weapons-select" TValue="int" @bind-SelectedValue="SelectedWeaponId">
            @foreach (GameItem weapon in WeaponList)
            {
                <SelectItem TValue="int" Value="@weapon.ItemTypeID">@weapon.Name</SelectItem>
            }
        </Select>
    </Column>
    <Column ColumnSize="ColumnSize.Is2">
        <Button id="attack-monster-btn" Color="Color.Secondary" Margin="Margin.Is1"
                Disabled="@disableAttack" Clicked="OnAttackClicked">
            Attack!
        </Button>
    </Column>
</Row>
<Row Margin="Margin.Is2.OnY">
    <Column ColumnSize="ColumnSize.Is6.Is2.WithOffset" Margin="Margin.Is1.FromTop">
        <Select id="consumables-select" TValue="int" @bind-SelectedValue="SelectedConsumableId">
            @foreach (GameItem item in ConsumableList)
            {
                <SelectItem TValue="int" Value="@item.ItemTypeID">@item.Name</SelectItem>
            }
        </Select>
    </Column>
    <Column ColumnSize="ColumnSize.Is2">
        <Button id="use-consumable-btn" Color="Color.Secondary" Margin="Margin.Is1"
                Disabled="@disableUse" Clicked="OnConsumeClicked">
            Use!
        </Button>
    </Column>
</Row>

@code {
    private bool disableAttack => !WeaponList.Any() || LocationHasMonster == false;
    private bool disableUse => !ConsumableList.Any();

    private int SelectedWeaponId { get; set; }
    private int SelectedConsumableId { get; set; }

    [Parameter]
    public IEnumerable<GameItem> WeaponList { get; set; } = Array.Empty<GameItem>();

    [Parameter]
    public IEnumerable<GameItem> ConsumableList { get; set; } = Array.Empty<GameItem>();

    [Parameter]
    public bool LocationHasMonster { get; set; } = false;

    [Parameter]
    public EventCallback<GameItem?> AttackClicked { get; set; }

    [Parameter]
    public EventCallback<GameItem?> ConsumeClicked { get; set; }

    protected override void OnInitialized()
    {
        SelectedWeaponId = WeaponList.Any() ? WeaponList.First().ItemTypeID : 0;
        SelectedConsumableId = ConsumableList.Any() ? ConsumableList.First().ItemTypeID : 0;
    }

    public void OnAttackClicked()
    {
        var weapon = SelectedWeaponId > 0 ? WeaponList.First(f => f.ItemTypeID == SelectedWeaponId) : null;
        AttackClicked.InvokeAsync(weapon);
    }

    public void OnConsumeClicked()
    {
        var item = SelectedConsumableId > 0 ? ConsumableList.First(f => f.ItemTypeID == SelectedConsumableId) : null;
        ConsumeClicked.InvokeAsync(item);
    }
}

This component copies the structure of the Attack select and button components to create the Consumable select and button components. And in response to the Use button click, we invoke the ConsumeClicked event callback to allow the view model to process that operation.

Let’s breakdown the updates we made to this component:

  • In lines #18-33, we define the markup for the select and button components (just like we did for the weapons and attack). But we bind them to the ConsumableList and OnConsumeClicked event handler.
  • Line #37 defines the disableUse member that calculates when the Use button should be enabled or disabled (when there are no items in the list).
  • Lines #45-46 defines the ConsumableList parameter that can be set by users of this component. The list is typically mapped to the player’s Inventory.Consumables list.
  • In lines #54-55, we define an EventCallback that is fired when an item is picked to be consumed.
  • In lines #69-73, the OnConsumeClicked figures out the item based on selected id and then invokes the ConsumeClicked event callback. The logic for consuming the item is not performed here, that is invoked in the main GameSession view model.

With the component updated, we need to fix the MainScreen page to pass ConsumableList and the ConsumeClicked handler to the CombatComponent.

@page "/"
@inject IGameSession ViewModel

<Row Margin="Margin.Is0" Style="height: 5vh; min-height: 32px">
    <Column ColumnSize="ColumnSize.Is12" Style="background-color: lightgrey">
        <Heading Size="HeadingSize.Is3">Simple RPG</Heading>
    </Column>
</Row>
<Row Margin="Margin.Is0" Style="height: 60vh">
    <Column ColumnSize="ColumnSize.Is3.OnWidescreen.Is12" Style="background-color: aquamarine">
        <PlayerComponent Player="@ViewModel.CurrentPlayer" />
    </Column>
    <Column ColumnSize="ColumnSize.Is9.OnWidescreen.Is12" Style="background-color: beige">
        <Row Margin="Margin.Is2.OnY">
            <Column ColumnSize="ColumnSize.Is8.OnWidescreen.Is12">
                <DisplayMessageListView Messages="@ViewModel.Messages" />
            </Column>
            <Column ColumnSize="ColumnSize.Is4.OnWidescreen.Is12">
                <LocationComponent Location="@ViewModel.CurrentLocation" />
                <MonsterComponent Monster="@ViewModel.CurrentMonster" />
                <TraderComponent Trader="@ViewModel.CurrentTrader" Player="@ViewModel.CurrentPlayer"
                                 InventoryChanged="@OnInventoryChanged"/>
            </Column>
        </Row>
    </Column>
</Row>
<Row Margin="Margin.Is0" Style="height: 33vh">
    <Column ColumnSize="ColumnSize.Is3.OnWidescreen.Is12" Padding="Padding.Is2.OnY"
            Style="background-color: burlywood">
        <PlayerTabs Player="@ViewModel.CurrentPlayer" />
    </Column>
    <Column ColumnSize="ColumnSize.Is9.OnWidescreen.Is12" Style="background-color: lavender">
        <Row Margin="Margin.Is2.OnY">
            <Column ColumnSize="ColumnSize.Is8.OnWidescreen.Is12">
                <CombatComponent WeaponList="@ViewModel.CurrentPlayer.Inventory.Weapons"
                                 AttackClicked="@ViewModel.AttackCurrentMonster"
                                 LocationHasMonster="@ViewModel.HasMonster"
                                 ConsumableList="@ViewModel.CurrentPlayer.Inventory.Consumables"
                                 ConsumeClicked="@ViewModel.ConsumeCurrentItem"/>
            </Column>
            <Column ColumnSize="ColumnSize.Is4.OnWidescreen.Is12">
                <MovementComponent Movement="@ViewModel.Movement" 
                                   LocationChanged="@ViewModel.OnLocationChanged" />
            </Column>
        </Row>
    </Column>
</Row>

@code {
    [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
    private void OnInventoryChanged()
    {
        StateHasChanged();
    }
}

In lines #38-39, we set ConsumableList to the ViewModel.CurrentPlayer.Inventory.Consumables property, and ConsumeClicked event callback to the ViewModel.ConsumeCurrentItem event handler. This allows the view model to consume the selected item and perform the Heal action on the CurrentPlayer.

We can again build and run our game to see the effects. Go to the herbalist’s garden and fight some snakes.

Fig 1 – Game screen with consumable selection and Use button

After taking damage, use the Granola bar to heal 2 points of damage. (Note: if the player is at full health, consuming the bar just uses the item but cannot raise health above the maximum hit points).

Fig 2 – Game screen after healing item consumed

In conclusion, we built the consumable items feature all the way from model classes to the presentation layer. We can now define and use items to heal our player. Next we are going to take a look at having recipes for items and the ability to craft items from those recipes.

One thought on “Lesson 4.4: Create the First Consumable Item

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 )

Facebook photo

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

Connecting to %s