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 theJsonSerializationHelper.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 theTraderFactory
. - We loop through each quest id and get the corresponding
Quest
object by id from theQuestFactory
. - We loop through each
MonsterEncounterItem
and add each one as aMonsterEncounter
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.