Currently we have the quest and recipe data concisely shown in the Quests and Recipes tables that only display their names and status. Once we get past the original receiving of the quest, it can be difficult to remember that is needed to complete a quest or recipe. So we are going to enable the player to see the description of those two entities on demand.
In the original tutorial series, this was done in WPF using tooltips. We could have done the same in our Blazor app (the Blazorise library even has a Tooltip
component). But hover tooltips are not a great experience for touch-enabled devices. So we are going to provide a different implementation to re-show the quest and recipe details in the Game Messages section of our screen.
When the user clicks on either a quest or recipe line item, then we will show each one’s description in that message area.
Refactoring Quest and Recipe
To enable this, we are going to implement some methods to build a DisplayMessage
with the appropriate description, requirements, and output. We want to encapsulate that information in the corresponding class, rather than in our GameSession
.
Let’s start with an update to the ItemQuantity
class.
using SimpleRPG.Game.Engine.Factories;
namespace SimpleRPG.Game.Engine.Models
{
public class ItemQuantity
{
public int ItemId { get; set; }
public int Quantity { get; set; }
public string QuantityItemDescription =>
$"{ItemFactory.GetItemName(ItemId)} (x{Quantity})";
}
}
The QuantityItemDescription
calculated property formats the item name and quantity in a user-readable format. It also uses the ItemFactory.GetItemName
method that we introduced in Lesson 4.4. We will use this property in our message formatting.
Then we need to update the Quest
class.
using System;
using System.Collections.Generic;
namespace SimpleRPG.Game.Engine.Models
{
public class Quest
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public IList<ItemQuantity> ItemsToComplete { get; set; } = Array.Empty<ItemQuantity>();
public int RewardExperiencePoints { get; set; }
public int RewardGold { get; set; }
public IList<ItemQuantity> RewardItems { get; set; } = Array.Empty<ItemQuantity>();
public DisplayMessage ToDisplayMessage()
{
var messageLines = new List<string>
{
Description,
"Items to complete the quest:"
};
foreach (ItemQuantity q in ItemsToComplete)
{
messageLines.Add(q.QuantityItemDescription);
}
messageLines.Add("Rewards for quest completion:");
messageLines.Add($"{RewardExperiencePoints} experience points");
messageLines.Add($"{RewardGold} gold");
foreach (ItemQuantity itemQuantity in RewardItems)
{
messageLines.Add(itemQuantity.QuantityItemDescription);
}
return new DisplayMessage($"Quest Added - {Name}", messageLines);
}
}
}
In this class we define the ToDisplayMessage
method (similar to Object.ToString
) which formats the internal quest information into a DisplayMessage
. The body of this function was actually extracted from the GameSession.GetQuestsAtLocation
method. We used to create the display message there originally (in lines #227-246), but it makes more sense to encapsulate this functionality with the Quest
class itself.
Next we make a similar update to the Recipe class.
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 });
}
}
public DisplayMessage ToDisplayMessage()
{
var messageLines = new List<string>
{
"Ingredients:"
};
foreach (ItemQuantity q in Ingredients)
{
messageLines.Add(q.QuantityItemDescription);
}
messageLines.Add("Creates:");
foreach (ItemQuantity itemQuantity in OutputItems)
{
messageLines.Add(itemQuantity.QuantityItemDescription);
}
return new DisplayMessage($"Recipe Added - {Name}", messageLines);
}
}
}
You will notice the Recipe.ToDisplayMessage
follows a similar structure to the Quest
version of the method. It shows each ingrddient and then each output (with appropriate labels), using the ItemQuantity.QuantityItemDescription
that we created earlier. Then it creates the DisplayMessage
with that data and returns the newly created message.
ViewModel Changes
With the model class changes in place, we can now update the GameSession
view model to use the model classes’ methods for display.
Let’s start with a simple update to the IGameSession
interface to expose a method that adds a DisplayMessage
to the game messages. We will use this method to handle click events on quests and recipes in the UI.
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);
void ProcessKeyPress(KeyProcessingEventArgs args);
void AddDisplayMessage(DisplayMessage message);
}
}
Then we make these two changes to the GameSession
implementation:
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;
private readonly Dictionary<string, Action> _userInputActions = new Dictionary<string, Action>();
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;
InitializeUserInputActions();
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);
}
}
public void ProcessKeyPress(KeyProcessingEventArgs args)
{
_ = args ?? throw new ArgumentNullException(nameof(args));
var key = args.Key.ToUpper();
if (_userInputActions.ContainsKey(key))
{
_userInputActions[key].Invoke();
}
}
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));
AddDisplayMessage(quest.ToDisplayMessage());
}
}
}
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 InitializeUserInputActions()
{
_userInputActions.Add("W", () => Movement.MoveNorth());
_userInputActions.Add("A", () => Movement.MoveWest());
_userInputActions.Add("S", () => Movement.MoveSouth());
_userInputActions.Add("D", () => Movement.MoveEast());
_userInputActions.Add("ARROWUP", () => Movement.MoveNorth());
_userInputActions.Add("ARROWLEFT", () => Movement.MoveWest());
_userInputActions.Add("ARROWDOWN", () => Movement.MoveSouth());
_userInputActions.Add("ARROWRIGHT", () => Movement.MoveEast());
}
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));
}
public void AddDisplayMessage(DisplayMessage message)
{
this.Messages.Insert(0, message);
if (Messages.Count > _maximumMessagesCount)
{
Messages.Remove(Messages.Last());
}
}
}
}
First, we removed the quest display code (which we extracted out earlier in this lesson), and instead called Quest.ToDisplayMessage
, and then added it to the Messages
list. Then, we just make the AddDisplayMessage
override method public, so that it matches the method defined in the IGameSession
. We already had this code, it was just not useable outside of this class.
Handling Clicks to Show Details
With the backing code wrapped up, we need to enable clicking on quests and recipes in their table. Let’s update the PlayersTab
component to enable this new behavior.
<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 id="quest-name-cell" Style="cursor:pointer" Clicked="() => OnQuestClicked(quest.PlayerQuest)">
@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 id="recipe-name-cell" Style="cursor: pointer" Clicked="() => OnRecipeClicked(recipe)">
@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; }
[Parameter]
public EventCallback<DisplayMessage> DisplayMessageCreated { get; set; }
public void OnSelectedTabChanged(string newTab)
{
_selectedTab = newTab;
}
private void OnQuestClicked(Quest quest) =>
DisplayMessageCreated.InvokeAsync(quest.ToDisplayMessage());
private void OnRecipeClicked(Recipe recipe) =>
DisplayMessageCreated.InvokeAsync(recipe.ToDisplayMessage());
}
- The changes are duplicated for both quest and recipe tables. First, we make the name cell clickable in the markup (lines #42-44 and #64-66). We change the style of the cell to set the cursor to the pointer when the mouse is over it. Then, we handle the
Clicked
event by forwarding the message to the appropriate event handler (OnQuestClicked
orOnRecipeClicked
). - Define an
EventCallback
(lines #91-92) for when a newDisplayMessage
is being invoked from this component… in response to the item clicks. - Two methods for handling the quest and recipe events (lines #99-103).
OnQuestClicked
knows how to convert aQuest
into its correspondingDisplayMessage
.OnRecipeClicked
knows how to convert aRecipe
into itsDisplayMessage
.- Both invoke the
DisplayMessageCreated
event.
As we can see, these are pretty simple changes to get the click behavior, and for the component to trigger its event. Now, we need to update the GameScreen
page to handle the EventCallback
properly to show the message in our UI.
@page "/"
@inject IJSRuntime jsRuntime
@inject IGameSession ViewModel
<div @onkeydown="@KeyDown" tabindex="0" @ref="pageRoot">
<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.Is4.OnWidescreen.Is12" Style="background-color: aquamarine">
<PlayerComponent Player="@ViewModel.CurrentPlayer" />
</Column>
<Column ColumnSize="ColumnSize.Is8.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="@StateHasChanged" />
</Column>
</Row>
</Column>
</Row>
<Row Margin="Margin.Is0" Style="height: 33vh">
<Column ColumnSize="ColumnSize.Is4.OnWidescreen.Is12" Padding="Padding.Is2.OnY"
Style="background-color: burlywood">
<PlayerTabs Player="@ViewModel.CurrentPlayer" CraftItemClicked="@ViewModel.CraftItemUsing"
DisplayMessageCreated="@ViewModel.AddDisplayMessage" />
</Column>
<Column ColumnSize="ColumnSize.Is8.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>
</div>
@code {
protected ElementReference pageRoot; // set the @ref for attribute
protected async override Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await jsRuntime.InvokeVoidAsync("SetFocusToElement", pageRoot);
}
}
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
protected void KeyDown(KeyboardEventArgs args) =>
ViewModel.ProcessKeyPress(args.ToKeyProcessingEventArgs());
}
As we can see we update the PlayerTabs
component (lines #32-33) to set its DisplayMessageCreated
event to the ViewModel.AddDisplayMessage
handler that we defined earlier. This code path now adds the provided DisplayMessage
to our GameSession.Messages
list and updates the display.
We’ve completed all of our changes again, so let’s build and run the game. If we click the Granola bar recipe right away, we will get the following:

If we move around the game world and fight, we can go back and see our quest as well by clicking on it.

We have made these aspects of our game more discoverable, so that players can remember how to complete a quest or ingredients for a recipe. It’s important to make features discoverable because it makes the games more fun for our users.
In the next lessons, we are going to look at expanding the capabilities of our combat system.