Lesson 4.13: Read World Data from File

As we continue moving our data from code to files, the world locations are the big source of data to convert. We will follow the same design principles as the last two lessons: JSON file with data, Data Transfer Objects for reading data and creating game objects, and refactoring corresponding game classes to be more restrictive. This pattern should be starting to get familiar…

Location Data Transfer Objects

First we will create the locations.json file and make it into an Embedded Resource as we did in lesson 4.11. Placing it into the SimpleRPG.Game.Engine project and Data folder.

[
  {
    "X": -2,
    "Y": -1,
    "Name": "Farmer's Field",
    "ImageName": "/images/locations/FarmFields.png",
    "Description": "There are rows of corn here, with giant rats hiding between them.",
    "Monsters": [
      { "Id": 2, "Perc": 100 }
    ]
  },
  {
    "X": -1,
    "Y": -1,
    "Name": "Farmer's House",
    "ImageName": "/images/locations/Farmhouse.png",
    "Description": "This is the house of your neighbor, Farmer Ted.",
    "TraderId": 102
  },
  {
    "X": 0,
    "Y": -1,
    "Name": "Home",
    "ImageName": "/images/locations/Home.png",
    "Description": "This is your home."
  },
  {
    "X": -1,
    "Y": 0,
    "Name": "Trading Shop",
    "ImageName": "/images/locations/Trader.png",
    "Description": "The shop of Susan, the trader.",
    "TraderId": 101
  },
  {
    "X": 0,
    "Y": 0,
    "Name": "Town Square",
    "ImageName": "/images/locations/TownSquare.png",
    "Description": "You see a fountain here."
  },
  {
    "X": 1,
    "Y": 0,
    "Name": "Town Gate",
    "ImageName": "/images/locations/TownGate.png",
    "Description": "There is a gate here, protecting the town from giant spiders."
  },
  {
    "X": 2,
    "Y": 0,
    "Name": "Spider Forest",
    "ImageName": "/images/locations/SpiderForest.png",
    "Description": "The trees in this forest are covered with spider webs.",
    "Monsters": [
      { "Id": 3, "Perc": 100 }
    ]
  },
  {
    "X": 0,
    "Y": 1,
    "Name": "Herbalist's Hut",
    "ImageName": "/images/locations/HerbalistsHut.png",
    "Description": "You see a small hut, with plants drying from the roof.",
    "TraderId": 103,
    "Quests": [ 1 ]
  },
  {
    "X": 0,
    "Y": 2,
    "Name": "Herbalist's Garden",
    "ImageName": "/images/locations/HerbalistsGarden.png",
    "Description": "There are many plants here, with snakes hiding behind them.",
    "Monsters": [
      { "Id": 1, "Perc": 100 }
    ]
  }
]

We moved all of the locations data from the WorldFactory to this new data file. It holds the data for all 9 locations in the initial game world. The main block of data in the file maps to the LocationTemplate class. And it has optional data at each location for trader id, quest ids, and MonsterEncounterItems.

Then we create the MonsterEncounterItem class in the SimpleRPG.Game.Engine project and Factories/DTO folder.

namespace SimpleRPG.Game.Engine.Factories.DTO
{
    public class MonsterEncounterItem
    {
        public int Id { get; set; }

        public int Perc { get; set; }
    }
}

This is a simple data class that just has a monster id and percentage value of the likelihood of its appearance at the associated location.

And we create the LocationTemplate class in the same folder.

using System;
using System.Collections.Generic;
using System.Text;

namespace SimpleRPG.Game.Engine.Factories.DTO
{
    public class LocationTemplate
    {
        public int X { get; set; }

        public int Y { get; set; }

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

        public string Description { get; set; } = string.Empty;

        public string ImageName { get; set; } = string.Empty;

        public int? TraderId { get; set; }

        public IEnumerable<int> Quests { get; set; } = new List<int>();

        public IEnumerable<MonsterEncounterItem> Monsters { get; set; } = new List<MonsterEncounterItem>();
    }
}

This class has many properties about the particular location, like its coordinates, name, description, etc. And it also has some optional parameters in the form of ids. In the DTOs we use the ids to retrieve referenced data. When we create the game objects, we convert the ids into actual instances of the appropriate classes. This layer of abstraction allows us to make the world location data file compact and loosely coupled from the implementation classes.

Re-write World Factory

The world factory was initially responsible for creating all of the location objects for the game world. However, by moving the location information to data files, we will need to change the WorldFactory to read the locations rather than create them. This dramatically changes the implementation code for the WorldFactory.

using D20Tek.Common.Helpers;
using SimpleRPG.Game.Engine.Factories.DTO;
using SimpleRPG.Game.Engine.Models;

namespace SimpleRPG.Game.Engine.Factories
{
    internal static class WorldFactory
    {
        private const string _resourceNamespace = "SimpleRPG.Game.Engine.Data.locations.json";

        internal static World CreateWorld()
        {
            var locationTemplates = JsonSerializationHelper.DeserializeResourceStream<LocationTemplate>(_resourceNamespace);
            var newWorld = new World();

            foreach (var template in locationTemplates)
            {
                var trader = (template.TraderId is null) ? null : TraderFactory.GetTraderById(template.TraderId.Value);
                var loc = new Location(template.X, template.Y, template.Name, template.Description,
                                       template.ImageName, trader);

                foreach (var questId in template.Quests)
                {
                    loc.QuestsAvailableHere.Add(QuestFactory.GetQuestById(questId));
                }

                foreach (var enc in template.Monsters)
                {
                    loc.AddMonsterEncounter(enc.Id, enc.Perc);
                }

                newWorld.AddLocation(loc);
            }

            return newWorld;
        }
    }
}
  • First, we see a much simpler WorldFactory with all of the location-related data removed.
  • We only have the single CreateWorld factory method because this creates a new instance of the world every time it is called. This typically only gets called once per game, so we don’t cache any template data here.
  • We load the LocationTemplates (line #13) using the JsonSerializationHelper.DeserializeResourceStream helper method (as we have for the other factories).
  • We loop through the templates and create a new Location for each one.
  • We get the Trader by its id from the TraderFactory.
  • We loop through each quest id and get the corresponding Quest object by id from the QuestFactory.
  • We loop through each MonsterEncounterItem and add each one as a MonsterEncounter for the current location.
  • If there are no quest ids or MonsterEncounterItems in the template, then the corresponding lists remain empty.
  • The last step in the template loop is to add the location to the new World instance.
  • Finally we return the newly created and populated World object.

With these changes in place, we are now loading our location data from file and delivering it via the same CreateWorld factory method, so even with a completely different implementation, our game engine code was insulated from these changes.

We do require one new method in the World model class to allow us to add a world Location individually. This is handled by the AddLocation method (lines #16-19).

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

namespace SimpleRPG.Game.Engine.Models
{
    public class World
    {
        private readonly IList<Location> _locations;

        public World(IEnumerable<Location>? locs = null)
        {
            _locations = locs is null ? new List<Location>() : locs.ToList();
        }

        internal void AddLocation(Location location)
        {
            _locations.Add(location);
        }

        public Location LocationAt(int xCoordinate, int yCoordinate)
        {
            var loc = _locations.FirstOrDefault(p => p.XCoordinate == xCoordinate && p.YCoordinate == yCoordinate);
            return loc ?? throw new ArgumentOutOfRangeException("Coordinates", "Provided coordinates could not be found in game world.");
        }

        public bool HasLocationAt(int xCoordinate, int yCoordinate)
        {
            return _locations.Any(p => p.XCoordinate == xCoordinate && p.YCoordinate == yCoordinate);
        }

        public Location GetHomeLocation()
        {
            return LocationAt(0, -1);
        }
    }
}

Refactoring Location Class

With our data format responsibility covered by the LocationTemplate, we can simplify the Location class and make its properties more restrictive. We will refactor the Location class with a new constructor and remove some property setters.

using SimpleRPG.Game.Engine.Factories;
using SimpleRPG.Game.Engine.Services;
using System;
using System.Collections.Generic;
using System.Linq;

namespace SimpleRPG.Game.Engine.Models
{
    public class Location
    {
        public Location(int x, int y, string name, string description, string image, Trader? trader = null)
        {
            XCoordinate = x;
            YCoordinate = y;
            Name = name;
            Description = description;
            ImageName = image;
            TraderHere = trader;
        }

        public int XCoordinate { get; }

        public int YCoordinate { get; }

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

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

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

        public IList<Quest> QuestsAvailableHere { get; } = new List<Quest>();

        public IList<MonsterEncounter> MonstersHere { get; } = new List<MonsterEncounter>();

        public Trader? TraderHere { get; set; } = null;

        public bool HasTrader => TraderHere != null;

        public void AddMonsterEncounter(int monsterId, int chanceOfEncountering)
        {
            if (MonstersHere.Any(m => m.MonsterId == monsterId))
            {
                // this monster has already been added to this location.
                // so overwrite the ChanceOfEncountering with the new number.
                MonstersHere.First(m => m.MonsterId == monsterId)
                            .ChanceOfEncountering = chanceOfEncountering;
            }
            else
            {
                // this monster is not already at this location, so add it.
                MonstersHere.Add(new MonsterEncounter(monsterId, chanceOfEncountering));
            }
        }

        public bool HasMonster() => MonstersHere.Any();

        public Monster GetMonster()
        {
            if (HasMonster() == false)
            {
                throw new InvalidOperationException();
            }

            // total the percentages of all monsters at this location.
            int totalChances = MonstersHere.Sum(m => m.ChanceOfEncountering);

            // Select a random number between 1 and the total (in case the total chances is not 100).
            var result = DiceService.Instance.Roll(totalChances);

            // loop through the monster list, 
            // adding the monster's percentage chance of appearing to the runningTotal variable.
            // when the random number is lower than the runningTotal, that is the monster to return.
            int runningTotal = 0;

            foreach (MonsterEncounter monsterEncounter in MonstersHere)
            {
                runningTotal += monsterEncounter.ChanceOfEncountering;

                if (result.Value <= runningTotal)
                {
                    return MonsterFactory.GetMonster(monsterEncounter.MonsterId);
                }
            }

            // If there was a problem, return the last monster in the list.
            return MonsterFactory.GetMonster(MonstersHere.Last().MonsterId);
        }
    }
}
  • The new constructor (lines #11-19) takes the parameters we want to set for the non-settable properties.
  • Each property definition (from lines #21-33) removes its property setter.

This model class refactoring was less intrusive than changes we made in previous classes, so there weren’t other changes needed in the game engine itself. But we did have one change to the LocationComponent.

@if (Location != null)
{
<div style="border: 1px solid gainsboro; text-align: center">
    <div>@Location.Name</div>
    <Figure>
        <FigureImage Source="@Location.ImageName" />
        <FigureCaption>@Location.Description</FigureCaption>
    </Figure>
</div>
}

@code {
    [Parameter]
    public Location? Location { get; set; }
}

We made the Location property nullable (line #14) because we no longer have a default constructor on the Location class. And we check that the property is not null (line #1) before we render the rest of the component. We cannot render any real information in the LocationComponent until we provide a Location to it.

Once again, there were some unit tests that needed to be fixed after this refactoring as well. Those changes are part of the commit for this lesson.

At this point, we can build and run our game again. We will load up in our home location again, and will be able to move around each location in the game world to encounter monsters, quests, and traders.

Fixing QuestFactory

With the latest changes to our WorldFactory, we actually introduced inconsistency in our test runs because the factory was not creating new instances of Quests when it should have been. We always want to keep our tests passing consistently to give us confidence in our code changes, so we are going to refactor the QuestFactory class as part of this lesson to address the test issues.

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

namespace SimpleRPG.Game.Engine.Factories
{
    internal static class QuestFactory
    {
        public static Quest GetQuestById(int id)
        {
            switch (id)
            {
                case 1:
                    // Declare the items need to complete the quest, and its reward items
                    List<ItemQuantity> itemsToComplete = new List<ItemQuantity>();
                    List<ItemQuantity> rewardItems = new List<ItemQuantity>();

                    itemsToComplete.Add(new ItemQuantity { ItemId = 9001, Quantity = 5 });
                    rewardItems.Add(new ItemQuantity { ItemId = 1002, Quantity = 1 });

                    // Create the quest
                    return new Quest
                    {
                        Id = 1,
                        Name = "Clear the herb garden",
                        Description = "Defeat the snakes in the Herbalist's garden",
                        ItemsToComplete = itemsToComplete,
                        RewardGold = 25,
                        RewardExperiencePoints = 10,
                        RewardItems = rewardItems
                    };
                default:
                    throw new ArgumentOutOfRangeException(nameof(id));

            }
        }
    }
}

We changed this class to create a new instance of the single Quest class when the caller asks for it. There is only one quest at this time, so we didn’t want to make this factory complicated. Also, we’re going to load quest data from file in our next lessons, so there isn’t much point to have a more robust implementation at this point.

With this change to the QuestFactory, our tests now return to running green consistently.

In conclusion, we now load our game location data from file. Our JSON file contains all of the data that used to be in the WorldFactory. We are now able to add new locations to the file and connect them to locations in our town. This is a great advancement in producing our game world. In the next lesson, we’re going to convert the remaining factories to load their data from JSON files as well.

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