Lesson 4.5: Craft Items with Recipes

With the popularity of games like Minecraft, building and crafting items from base components has become very common in games. It has found its way into many of the latest roleplaying games as well. So let’s build the concept of recipes (ingredients for making items) and crafting into our game engine.

Recipe Model Changes

The Recipe class is the basis of our crafting system. It is actually similar in structure to the Quest class. It has a set of ingredients required for the recipe… represented as a list of ItemQuantity for each item type and amount of items required. And it has a set of produced items that are the result of the crafting process (so it can also be multiple items).

Let’s create the Recipe class in the SimpleRPG.Game.Engine project and Models folder.

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

namespace SimpleRPG.Game.Engine.Models
{
    public class Recipe
    {
        public Recipe(int id, string name)
        {
            Id = id;
            Name = name;
        }

        public int Id { get; }

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

        public IList<ItemQuantity> Ingredients { get; } = new List<ItemQuantity>();

        public IList<ItemQuantity> OutputItems { get; } = new List<ItemQuantity>();

        public void AddIngredient(int itemId, int quantity)
        {
            if (!Ingredients.Any(x => x.ItemId == itemId))
            {
                Ingredients.Add(new ItemQuantity { ItemId = itemId, Quantity = quantity });
            }
        }

        public void AddOutputItem(int itemId, int quantity)
        {
            if (!OutputItems.Any(x => x.ItemId == itemId))
            {
                OutputItems.Add(new ItemQuantity { ItemId = itemId, Quantity = quantity });
            }
        }
    }
}

We can see the property structure we described above: recipe Id, Name used for display, required Ingredients list, and OutputItems list.

We also have a couple of helper methods: AddIngredient and AddOutputItem that ensure that there no duplicate ingredients or output items.

Next we need to create a RecipeFactory as we did for our other model classes – in the Factories folder.

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

namespace SimpleRPG.Game.Engine.Factories
{
    internal static class RecipeFactory
    {
        private static readonly List<Recipe> _recipes = new List<Recipe>();

        static RecipeFactory()
        {
            Recipe granolaBar = new Recipe(1, "Granola bar recipe");
            granolaBar.AddIngredient(3001, 1);
            granolaBar.AddIngredient(3002, 1);
            granolaBar.AddIngredient(3003, 1);
            granolaBar.AddOutputItem(2001, 1);

            _recipes.Add(granolaBar);
        }

        public static Recipe GetRecipeById(int id)
        {
            return _recipes.First(x => x.Id == id);
        }
    }
}

Like our other factories, this one creates an instance of our only recipe (to create a granola bar), and provides a GetRecipeById method to retrieve the required recipe by its Id.

As you can see the RecipeFactory references some new game items as ingredients: oats, honey, and raisins. Next we need to update the ItemFactory class to define instances of these three new item types (lines #22-24). These are just simple miscellaneous 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(3001, "Oats", 1);
            BuildMiscellaneousItem(3002, "Honey", 2);
            BuildMiscellaneousItem(3003, "Raisins", 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();
        }

        public static string GetItemName(int itemTypeId)
        {
            return _standardGameItems.FirstOrDefault(i => i.ItemTypeID == itemTypeId)?.Name ?? "";
        }

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

Then, we need to give recipes to our Player. We will do that by defining a Recipes list and a LearnRecipe method.

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

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

        public int ExperiencePoints { get; set; }

        public IList<QuestStatus> Quests { get; set; } = new List<QuestStatus>();

        public IList<Recipe> Recipes { get; set; } = new List<Recipe>();

        public void AddExperience(int experiencePoints)
        {
            if (experiencePoints > 0)
            {
                ExperiencePoints += experiencePoints;
                SetLevelAndMaximumHitPoints();
            }
        }

        private void SetLevelAndMaximumHitPoints()
        {
            int originalLevel = Level;

            Level = (ExperiencePoints / 100) + 1;

            if (Level != originalLevel)
            {
                MaximumHitPoints = Level * 10;
            }
        }

        public void LearnRecipe(Recipe recipe)
        {
            if (!Recipes.Any(r => r.Id == recipe.Id))
            {
                Recipes.Add(recipe);
            }
        }
    }
}

The Recipes property (line #14) holds all of the recipes that this player has learned. And the LearnRecipe method (lines #37-43) adds the specified recipe to the list, but only it is unique (based on Id).

Finally, we add the RemoveItems method to our Inventory class.

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 void RemoveItems(IList<ItemQuantity> itemQuantities)
        {
            _ = itemQuantities ?? throw new ArgumentNullException(nameof(itemQuantities));

            foreach (ItemQuantity itemQuantity in itemQuantities)
            {
                for (int i = 0; i < itemQuantity.Quantity; i++)
                {
                    RemoveItem(Items.First(item => item.ItemTypeID == itemQuantity.ItemId));
                }
            }
        }

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

The RemoveItems method (lines #85-96) removes a list of ItemQuantity objects as a bulk remove operation. This method’s code was extracted from the GameSession.CompleteQuestsAtLocation method, which also needs to remove a list of items from the player’s inventory. Rather than duplicate this code in two places, we created an Inventory method to perform this operation (just like the single Inventory.RemoveItem method).

GameSession Updates

With the model and factory class changes complete, we will implement the craft operation. This method contains the logic required to create an item from a given recipe.

First, we define the CraftItemUsing method in the IGameSession interface.

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

        void CraftItemUsing(Recipe recipe);
    }
}

Then, we implement that method in the GameSession class.

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));
            CurrentPlayer.LearnRecipe(RecipeFactory.GetRecipeById(1));
            CurrentPlayer.Inventory.AddItem(ItemFactory.CreateGameItem(3001));
            CurrentPlayer.Inventory.AddItem(ItemFactory.CreateGameItem(3002));
            CurrentPlayer.Inventory.AddItem(ItemFactory.CreateGameItem(3003));
        }

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

        public void CraftItemUsing(Recipe recipe)
        {
            _ = recipe ?? throw new ArgumentNullException(nameof(recipe));

            var lines = new List<string>();

            if (CurrentPlayer.Inventory.HasAllTheseItems(recipe.Ingredients))
            {
                CurrentPlayer.Inventory.RemoveItems(recipe.Ingredients);

                foreach (ItemQuantity itemQuantity in recipe.OutputItems)
                {
                    for (int i = 0; i < itemQuantity.Quantity; i++)
                    {
                        GameItem outputItem = ItemFactory.CreateGameItem(itemQuantity.ItemId);
                        CurrentPlayer.Inventory.AddItem(outputItem);
                        lines.Add($"You craft 1 {outputItem.Name}");
                    }
                }

                AddDisplayMessage("Item Creation", lines);
            }
            else
            {
                lines.Add("You do not have the required ingredients:");
                foreach (ItemQuantity itemQuantity in recipe.Ingredients)
                {
                    lines.Add($"  {itemQuantity.Quantity} {ItemFactory.GetItemName(itemQuantity.ItemId)}");
                }

                AddDisplayMessage("Item Creation", lines);
            }
        }

        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
                        CurrentPlayer.Inventory.RemoveItems(quest.ItemsToComplete);

                        // 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());
            }
        }
    }
}

There are a few changes to explain in more detail:

  1. We give the “Granola bar recipe” and all of its ingredients to the CurrentPlayer in the GameSession constructor (lines #62-65).
  2. We implement the CraftItemUsing method (lines #134-166) by:
    • checking if the CurrentPlayer has all of the required ingredients for the Recipe.
    • if it does, we remove all of the ingredients from their inventory.
    • add all output items from the recipe to the player’s inventory.
    • and display a message to user stating which items were just created.
    • otherwise, if the player is missing ingredients, then we display a message stating recipe’s required ingredients.
  3. We update the CompleteQuestsAtLocation method (line #251) to remove the code block that deleted the required items from the player’s inventory. Instead we call the Inventory.RemoveItems method with the Quest.ItemsToComplete list.

With these changes our game engine now has the code required to craft items from recipes.

Presentation Changes

To show the player’s recipes and give the user a way to craft items with those recipes, we are going to update the PlayerTabs component. Remember this is the <TabControl> that shows player inventory and quests, so we will add a third tab to show their known recipes.

<Tabs SelectedTab="@_selectedTab" Pills="true" SelectedTabChanged="OnSelectedTabChanged">
    <Items>
        <Tab Name="inventory">Inventory</Tab>
        <Tab Name="quests">Quests</Tab>
        <Tab Name="recipes">Recipes</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">
            <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>Done?</TableRowCell>
                    </TableHeader>
                    <TableBody>
                        @foreach (var quest in Player.Quests)
                        {
                        <TableRow>
                            <TableRowCell>@quest.PlayerQuest.Name</TableRowCell>
                            <TableRowCell>@(quest.IsCompleted ? "Yes" : "No")</TableRowCell>
                        </TableRow>
                        }
                    </TableBody>
                </Table>
            </div>
        </TabPanel>
        <TabPanel Name="recipes">
            <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></TableRowCell>
                    </TableHeader>
                    <TableBody>
                        @foreach (var recipe in Player.Recipes)
                        {
                        <TableRow>
                            <TableRowCell>@recipe.Name</TableRowCell>
                            <TableRowCell>
                                <Button id="craft-item-btn" Size="ButtonSize.Small" Color="Color.Secondary"
                                        Outline="true" Clicked="() => CraftItemClicked.InvokeAsync(recipe)">
                                    Craft
                                </Button>
                            </TableRowCell>
                        </TableRow>
                        }
                    </TableBody>
                </Table>
            </div>
        </TabPanel>
    </Content>
</Tabs>

@code {
    private string _selectedTab = "inventory";

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

    [Parameter]
    public EventCallback<Recipe> CraftItemClicked { get; set; }

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

Let’s dissect these changes:

  1. Define the third tab named Recipes (line #5).
  2. In the @code section, we define the CraftItemClicked event callback (lines #84-85) to notify external components that the craft operation should be performed. The corresponding Recipe is passed with the event callback.
  3. In the recipes panel, define another <Table> that shows the list of player’s Recipes. The structure of this table is similar to the table in the Quest tab as well. But we bind it to the Recipes list instead.
  4. Note that we place a Craft button on each line of the table. The button Clicked event then forwards the call and associated Recipe to the CraftItemClicked event callback.

With these changes we can show the user their list of known recipes, and let them select a recipe to craft.

Finally, we need to connect it all up with our GameScreen:

@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" CraftItemClicked="@ViewModel.CraftItemUsing" />
    </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();
    }
}

We already defined the PlayerTabs control in previous lessons, so it is already defined and has all of the data it needs to show Player.Recipes. All we really need to do is connect the component’s CraftItemClicked event callback to the ViewModel.CraftItemUsing handler method.

Let’s rebuild and run the game again. Everything should work well. If we go to the Recipes tab in the bottom-left of the screen, we will see a single recipe for a Granola bar. If we click the Craft button, we will get a message saying the item was created successfully and our inventory should now have 2 granola bars.

Fig 1 – Granola bar recipe completed

In conclusion, we completed another full feature end-to-end from our backend model changes, through the view model, and showing the feature in our presentation layer.

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