We are going to continue our move to data files by working on the monster data. We will follow the same design that we described in the last lesson. We will create a new JSON data file, create a couple of Data Transfer Objects to use in serialization, and update the MonsterFactory
to load and return monster instances.
Monster Data Transfer Objects
First we will create a new monster.json data file in the SimpleRPG.Game.Engine project and Data folder. Remember to set the monster.json file Build Action to Embedded Resource (like we did last lesson).
[
{
"Id": 1,
"Name": "Snake",
"Dex": 15,
"Str": 12,
"AC": 10,
"MaxHP": 4,
"WeaponId": 1501,
"RewardXP": 5,
"Gold": 1,
"Image": "/images/monsters/snake.png",
"LootItems": [
{ "Id": 9001, "Perc": 25 },
{ "Id": 9002, "Perc": 75 }
]
},
{
"Id": 2,
"Name": "Rat",
"Dex": 8 ,
"Str": 10,
"AC": 10,
"MaxHP": 5,
"WeaponId": 1502,
"RewardXP": 5,
"Gold": 1,
"Image": "/images/monsters/rat.png",
"LootItems": [
{ "Id": 9003, "Perc": 25 },
{ "Id": 9004, "Perc": 75 }
]
},
{
"Id": 3,
"Name": "Giant Spider",
"Dex": 12,
"Str": 15,
"AC": 12,
"MaxHP": 10,
"WeaponId": 1503,
"RewardXP": 10,
"Gold": 3,
"Image": "/images/monsters/giant-spider.png",
"LootItems": [
{ "Id": 9005, "Perc": 25 },
{ "Id": 9006, "Perc": 75 }
]
}
]
As we can see, we save monster data for the same 3 creatures that we supported in code. We copy all of the data into this file. And since its more complex data, we put each property on its own line so that the file remains readable.
Then to hold the loot information for a monster, we create the LootItem
class in the SimpleRPG.Game.Engine project and Factories/DTO folder. Remember our DTO classes are simple classes, no logic, properties with getters and setters only.
namespace SimpleRPG.Game.Engine.Factories.DTO
{
public class LootItem
{
public int Id { get; set; }
public int Perc { get; set; }
}
}
Next we create a MonsterTemplate
DTO class in the same folder.
using System.Collections.Generic;
namespace SimpleRPG.Game.Engine.Factories.DTO
{
public class MonsterTemplate
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public int Dex { get; set; }
public int Str { get; set; }
public int AC { get; set; }
public int MaxHP { get; set; }
public int WeaponId { get; set; }
public int RewardXP { get; set; }
public int Gold { get; set; }
public string Image { get; set; } = string.Empty;
public IEnumerable<LootItem> LootItems { get; set; } = new List<LootItem>();
}
}
This class has all of the properties that match the JSON data file. With this DTO, we are able to:
- Use different, shorter property names to minimize file size.
- Reference the monster’s weapon by Id (since we want to get the actual instance of the item from the
ItemFactory
). - Define a list of possible loot items, which also reference game items by id.
We again use the JsonSerializationHelper
to load MonsterTemplates
from our file.
Re-write MonsterFactory
With loading our data, our MonsterFactory
is going to work very differently. We are basically re-writing this class. Our design is to load the MonsterTemplates
from file, cache those templates in the factory, and in the GetMonster
method, we will find the template by id and create new instances of the Monster
from that template.
using D20Tek.Common.Helpers;
using SimpleRPG.Game.Engine.Factories.DTO;
using SimpleRPG.Game.Engine.Models;
using SimpleRPG.Game.Engine.Services;
using System.Collections.Generic;
using System.Linq;
namespace SimpleRPG.Game.Engine.Factories
{
internal static class MonsterFactory
{
private const string _resourceNamespace = "SimpleRPG.Game.Engine.Data.monsters.json";
private static readonly IList<MonsterTemplate> _monsterTemplates = JsonSerializationHelper.DeserializeResourceStream<MonsterTemplate>(_resourceNamespace);
public static Monster GetMonster(int monsterId, IDiceService? dice = null)
{
dice ??= DiceService.Instance;
// first find the monster template by its id.
var template = _monsterTemplates.First(p => p.Id == monsterId);
// then create an instance of monster from that template.
var weapon = ItemFactory.CreateGameItem(template.WeaponId);
var monster = new Monster(template.Id, template.Name, template.Image, template.Dex, template.Str,
template.AC, template.MaxHP, weapon, template.RewardXP, template.Gold);
// finally add random loot for this monster instance.
foreach(var loot in template.LootItems)
{
AddLootItem(monster, loot.Id, loot.Perc, dice);
}
return monster;
}
private static void AddLootItem(Monster monster, int itemID, int percentage, IDiceService dice)
{
if (dice.Roll("1d100").Value <= percentage)
{
monster.Inventory.AddItem(ItemFactory.CreateGameItem(itemID));
}
}
}
}
- We load and cache the
MonsterTemplates
(line #18) using theJsonSerializationHelper.DeserializeResourceStream
helper method. - The
GetMonster
factory method does the following:- Retrieves the monster template by id (line #20).
- Creates the monster’s weapon by using the
MonsterTemplate.WeaponId
and theItemFactory
(line #23). - Create the
Monster
object with corresponding properties from theMonsterTemplate
and the weapon we just created (lines #24-25). - Then for each
LootItem
in theMonsterTemplate
, we callAddLootItem
(which already existed in this factory implementation) (lines #28-31). - Finally we return the new instance of the requested
Monster
.
- The
AddLootItem
method remains largely the same and randomly decides whether the specified item is found on thisMonster
.
We see some of the power of using Data Transfer Objects in this factory. We are able to create an object graph for Monster
. Not only creating Monster
itself, but its weapon using an id from the template. And then populate the Monster's
inventory with loot. And making the loot dynamic based on a percentage defined in the template. Using the same MonsterTemplate
, we can create dynamic and different instances of Monster
each time.
Refactoring LivingEntity
Like we did with the GameItem
class, we are going to refactor LivingEntity
to tighten up control of access to properties and how data changes. This is a large change but necessary to not leave our classes open for random changes. If we had started with more restrictive class definitions, then this refactoring would not be necessary. But change is constant in software development, so learning to refactor your code to make it better is a great skill to have. Also having a large set of unit tests ensures that we can fully test our functionality while safely making all of these changes.
We’re going to start with changes to the LivingEntity
class itself:
using System;
namespace SimpleRPG.Game.Engine.Models
{
public abstract class LivingEntity
{
public LivingEntity(int id, string name, int dex, int str, int ac,
int currentHitPoints, int maximumHitPoints, int gold)
{
Id = id;
Name = name;
Dexterity = dex;
Strength = str;
ArmorClass = ac;
CurrentHitPoints = currentHitPoints;
MaximumHitPoints = maximumHitPoints;
Gold = gold;
}
public int Id { get; }
public string Name { get; } = string.Empty;
public int CurrentHitPoints { get; private set; }
public int MaximumHitPoints { get; protected set; }
public int Dexterity { get; } = 10;
public int Strength { get; } = 10;
public int ArmorClass { get; } = 10;
public int Gold { get; private set; }
public int Level { get; protected set; } = 1;
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 IsAlive => CurrentHitPoints > 0;
public bool IsDead => !IsAlive;
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;
}
}
}
}
First, we define a constructor for LivingEntity
(lines #7-18). We want all of our object created via this constructor. And we do not define a default constructor. Then, we remove any property setters that are unnecessary because those properties are only settable through the constructor. Other properties get private or protected setters if they get changed by code in this class or derived classes. We want to start with the strictest access and open it up as needed by our functionality.
With this change in place, we need to update each class derives from LivingEntity
. We will start with the simplest, Trader
.
namespace SimpleRPG.Game.Engine.Models
{
public class Trader : LivingEntity
{
public Trader(int id, string name)
: base(id, name, 10, 10, 10, 999, 999, 100)
{
}
}
}
We just define a simple constructor for Trader
with its id and name, and we default all of the other variables in the LivingEntity
constructor (since we don’t care about properties like dexterity or hit points).
Then, we need to refactor the Monster
class:
namespace SimpleRPG.Game.Engine.Models
{
public class Monster : LivingEntity
{
public Monster(int id, string name, string imageName, int dex, int str, int ac,
int maximumHitPoints, GameItem currentWeapon,
int rewardExperiencePoints, int gold) :
base(id, name, dex, str, ac, maximumHitPoints, maximumHitPoints, gold)
{
ImageName = imageName;
CurrentWeapon = currentWeapon;
RewardExperiencePoints = rewardExperiencePoints;
}
public string ImageName { get; } = string.Empty;
public int RewardExperiencePoints { get; }
}
}
The Monster
constructor has many of the same parameters as LivingEntity
, but also additional parameters that are defined in this derived class, like ImageName
, RewardsExperience
, and CurrentWeapon
. We use the base constructor call for the shared parameters and set the specific ones in this constructor.
The only other thing is removing the setters from the two properties in this class.
The last model class change is to Player:
using System.Collections.Generic;
using System.Linq;
namespace SimpleRPG.Game.Engine.Models
{
public class Player : LivingEntity
{
public static readonly Player Empty = new Player(string.Empty, string.Empty, 10, 10, 10, 10, 0);
public Player(string name, string charClass, int dex, int str, int ac,
int maximumHitPoints, int gold)
: base(1, name, dex, str, ac, maximumHitPoints, maximumHitPoints, gold)
{
CharacterClass = charClass;
}
public string CharacterClass { get; } = string.Empty;
public int ExperiencePoints { get; private set; }
public IList<QuestStatus> Quests { get; } = new List<QuestStatus>();
public IList<Recipe> Recipes { get; } = 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);
}
}
}
}
- Again we create a constructor with all the parameters that we want to set a creation time (lines #10-15). Use the base constructor for the share parameters, and set
Player
-specific properties in this constructor. - We remove all of the property setters, except for the
ExperiencePoints
property (line #19), which we make private. - Finally we add a static
Empty
property (line #8) as a convenience for our UI components that used empty Player objects as a placeholder.
With all of these model class changes in place, we need to update the code that creates these classes. We already covered the MonsterFactory
code changes above. Here we have the TraderFactory
update:
using SimpleRPG.Game.Engine.Models;
using System.Collections.Generic;
using System.Linq;
namespace SimpleRPG.Game.Engine.Factories
{
internal static class TraderFactory
{
private static readonly List<Trader> _traders = new List<Trader>();
static TraderFactory()
{
_traders.Add(CreateTrader(101, "Susan"));
_traders.Add(CreateTrader(102, "Farmer Ted"));
_traders.Add(CreateTrader(103, "Pete the Herbalist"));
}
public static Trader GetTraderById(int id) => _traders.First(t => t.Id == id);
private static Trader CreateTrader(int id, string name)
{
Trader t = new Trader(id, name);
t.Inventory.AddItem(ItemFactory.CreateGameItem(1001));
return t;
}
}
}
We replaced the object initialization code with the constructor call in line #22. Since the defaults are captured in the Trader
constructor, we removed them from our factory code… simplifying this code greatly.
Finally, the GameSession
player creation code was updated to use the Player
constructor too (lines #47-48):
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 Battle _battle;
private readonly int _maximumMessagesCount = 100;
private readonly Dictionary<string, Action> _userInputActions = new Dictionary<string, Action>();
private readonly IDiceService _diceService = DiceService.Instance;
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, IDiceService? diceService = null)
: this()
{
_maximumMessagesCount = maxMessageCount;
_diceService = diceService ?? DiceService.Instance;
}
public GameSession()
{
InitializeUserInputActions();
_battle = new Battle(
() => OnLocationChanged(_currentWorld.GetHomeLocation()), // Return to Player's home
() => GetMonsterAtCurrentLocation(), // Gets another monster
_diceService);
CurrentPlayer = new Player("DarthPedro", "Fighter", _diceService.Roll(6, 3).Value,
_diceService.Roll(6, 3).Value, 10, 10, 10);
_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 != null)
{
CurrentPlayer.CurrentWeapon = currentWeapon;
_battle.Attack(CurrentPlayer, 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 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());
}
}
}
}
The bulk of the refactoring changes was in the game engine, but there were a couple of components that changed to use the newly defined Player.Empty
property.
First, the PlayerComponent
in line 43:
<Table Borderless="true" Narrow="true">
<TableHeader>
<TableHeaderCell RowSpan="2">Player Data</TableHeaderCell>
</TableHeader>
<TableBody>
<TableRow>
<TableRowCell>Name:</TableRowCell>
<TableRowCell>@Player.Name</TableRowCell>
</TableRow>
<TableRow>
<TableRowCell>Class:</TableRowCell>
<TableRowCell>@Player.CharacterClass</TableRowCell>
</TableRow>
<TableRow>
<TableRowCell>Strength:</TableRowCell>
<TableRowCell>@Player.Strength</TableRowCell>
</TableRow>
<TableRow>
<TableRowCell>Dexterity:</TableRowCell>
<TableRowCell>@Player.Dexterity</TableRowCell>
</TableRow>
<TableRow>
<TableRowCell>Hit points:</TableRowCell>
<TableRowCell>@Player.CurrentHitPoints</TableRowCell>
</TableRow>
<TableRow>
<TableRowCell>Gold:</TableRowCell>
<TableRowCell>@Player.Gold</TableRowCell>
</TableRow>
<TableRow>
<TableRowCell>XP:</TableRowCell>
<TableRowCell>@Player.ExperiencePoints</TableRowCell>
</TableRow>
<TableRow>
<TableRowCell>Level:</TableRowCell>
<TableRowCell>@Player.Level</TableRowCell>
</TableRow>
</TableBody>
</Table>
@code {
[Parameter]
public Player Player { get; set; } = Player.Empty;
}
And the PlayerTabs
in line 86:
<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; } = Player.Empty;
[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());
}
For brevity, I am glossing over all of the code changes required to our unit tests. These were similar changes to have our test code use the new class constructors, but there are many changes. But, you can review this commit to see the full breadth of the test refactoring.
With all of these code changes in place, we can build our project again. Our tests will all pass successfully again. And we can run our game and see the same functionality but with our monsters now being loaded from a data file. We can add or modify monster data in the JSON file and see it reflected in the game.
We will continue on our trek to get all of our game data loaded from file by looking at the world data next.