Lesson 4.12: Read Monster Data File

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 the JsonSerializationHelper.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 the ItemFactory (line #23).
    • Create the Monster object with corresponding properties from the MonsterTemplate and the weapon we just created (lines #24-25).
    • Then for each LootItem in the MonsterTemplate, we call AddLootItem (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 this Monster.

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.

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 )

Google photo

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

Twitter picture

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

Facebook photo

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

Connecting to %s