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:
- We give the “Granola bar recipe” and all of its ingredients to the
CurrentPlayer
in theGameSession
constructor (lines #62-65). - We implement the
CraftItemUsing
method (lines #134-166) by:- checking if the
CurrentPlayer
has all of the required ingredients for theRecipe
. - 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.
- checking if the
- 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 theInventory.RemoveItems
method with theQuest.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:
- Define the third tab named Recipes (line #5).
- In the
@code
section, we define theCraftItemClicked
event callback (lines #84-85) to notify external components that the craft operation should be performed. The correspondingRecipe
is passed with the event callback. - In the recipes panel, define another
<Table>
that shows the list of player’sRecipes
. The structure of this table is similar to the table in the Quest tab as well. But we bind it to theRecipes
list instead. - Note that we place a Craft button on each line of the table. The button
Clicked
event then forwards the call and associatedRecipe
to theCraftItemClicked
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.

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.