The ItemQuantity class has a circular dependency with the ItemFactory class, to get the item’s name.
Once we move the models to SOSCSRPG.Models, and the services to SOSCSRPG.Services, we can’t have that circular dependency. So, we’ll fix that in this lesson.
Step 1: Move Engine\LoggingService.cs to SOSCSRPG.Services
While looking at the services, I saw we can easily move the LoggingService into its new home – the SOSCSRPG.Services project.
Cut-and-paste the class into its new project.
Step 2: Modify SOSCSRPG.Services\LoggingService’s namespace.
Change its namespace from “Engine.Services” to “SOSCSRPG.Services”.
LoggingService.cs
using System;
using System.IO;
namespace SOSCSRPG.Services
{
public static class LoggingService
{
private const string LOG_FILE_DIRECTORY = "Logs";
static LoggingService()
{
string logDirectory = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, LOG_FILE_DIRECTORY);
if(!Directory.Exists(logDirectory))
{
Directory.CreateDirectory(logDirectory);
}
}
public static void Log(Exception exception, bool isInnerException = false)
{
using(StreamWriter sw = new StreamWriter(LogFileName(), true))
{
sw.WriteLine(isInnerException ? "INNER EXCEPTION" : $"EXCEPTION: {DateTime.Now}");
sw.WriteLine(new string(isInnerException ? '-' : '=', 40));
sw.WriteLine($"{exception.Message}");
sw.WriteLine($"{exception.StackTrace}");
sw.WriteLine(); // Blank line, to make the log file easier to read
}
if(exception.InnerException != null)
{
Log(exception.InnerException, true);
}
}
private static string LogFileName()
{
// This will create a separate log file for each day.
// Not that we're hoping to have many days of errors.
return Path.Combine(AppDomain.CurrentDomain.BaseDirectory, LOG_FILE_DIRECTORY,
$"SOSCSRPG_{DateTime.Now:yyyyMMdd}.log");
}
}
}
Step 3: Update namespace in WPFUI\App.xaml.cs
App.xaml.cs is the only class that uses the LoggingService, so we need to update its using directives to include the new one for LoggingService.
App.xaml.cs
using System.Windows;
using System.Windows.Threading;
using SOSCSRPG.Services;
namespace WPFUI
{
public partial class App : Application
{
private void App_OnDispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e)
{
string exceptionMessageText =
$"An exception occurred: {e.Exception.Message}\r\n\r\nat: {e.Exception.StackTrace}";
LoggingService.Log(e.Exception);
// TODO: Create a Window to display the exception information.
MessageBox.Show(exceptionMessageText, "Unhandled Exception", MessageBoxButton.OK);
}
}
}
Step 4: Modify Engine\Models\ItemQuantity.cs
Change the constructor to accept a GameItem object and store it in a backing variable.
Change the properties to use values from the backing GameItem object.
ItemQuantity.cs
namespace Engine.Models
{
public class ItemQuantity
{
private readonly GameItem _gameItem;
public int ItemID => _gameItem.ItemTypeID;
public int Quantity { get; }
public string QuantityItemDescription =>
$"{Quantity} {_gameItem.Name}";
public ItemQuantity(GameItem item, int quantity)
{
_gameItem = item;
Quantity = quantity;
}
}
}
Step 5: Modify Engine\Factories\QuestFactory.cs
Change the LoadQuestsFromNodes() function to pass in a GameItem to the ItemQuantity constructor, instead of the ID.
QuestFactory.cs
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Xml;
using Engine.Models;
using Engine.Shared;
namespace Engine.Factories
{
internal static class QuestFactory
{
private const string GAME_DATA_FILENAME = ".\\GameData\\Quests.xml";
private static readonly List<Quest> _quests = new List<Quest>();
static QuestFactory()
{
if(File.Exists(GAME_DATA_FILENAME))
{
XmlDocument data = new XmlDocument();
data.LoadXml(File.ReadAllText(GAME_DATA_FILENAME));
LoadQuestsFromNodes(data.SelectNodes("/Quests/Quest"));
}
else
{
throw new FileNotFoundException($"Missing data file: {GAME_DATA_FILENAME}");
}
}
private static void LoadQuestsFromNodes(XmlNodeList nodes)
{
foreach(XmlNode node in nodes)
{
// Declare the items need to complete the quest, and its reward items
List<ItemQuantity> itemsToComplete = new List<ItemQuantity>();
List<ItemQuantity> rewardItems = new List<ItemQuantity>();
foreach(XmlNode childNode in node.SelectNodes("./ItemsToComplete/Item"))
{
GameItem item = ItemFactory.CreateGameItem(childNode.AttributeAsInt("ID"));
itemsToComplete.Add(new ItemQuantity(item, childNode.AttributeAsInt("Quantity")));
}
foreach(XmlNode childNode in node.SelectNodes("./RewardItems/Item"))
{
GameItem item = ItemFactory.CreateGameItem(childNode.AttributeAsInt("ID"));
rewardItems.Add(new ItemQuantity(item, childNode.AttributeAsInt("Quantity")));
}
_quests.Add(new Quest(node.AttributeAsInt("ID"),
node.SelectSingleNode("./Name")?.InnerText ?? "",
node.SelectSingleNode("./Description")?.InnerText ?? "",
itemsToComplete,
node.AttributeAsInt("RewardExperiencePoints"),
node.AttributeAsInt("RewardGold"),
rewardItems));
}
}
internal static Quest GetQuestByID(int id)
{
return _quests.FirstOrDefault(quest => quest.ID == id);
}
}
}
Step 6: Modify Engine\Models\Recipe.cs
Modify constructor to accept ingredients and outputItems.
Delete AddIngredient() and AddOutputItem() functions.
Remove default empty list population for Ingredients and OutputItems properties.
Recipe.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;
namespace Engine.Models
{
public class Recipe
{
public int ID { get; }
[JsonIgnore]
public string Name { get; }
[JsonIgnore] public List<ItemQuantity> Ingredients { get; }
[JsonIgnore]
public List<ItemQuantity> OutputItems { get; }
[JsonIgnore]
public string ToolTipContents =>
"Ingredients" + Environment.NewLine +
"===========" + Environment.NewLine +
string.Join(Environment.NewLine, Ingredients.Select(i => i.QuantityItemDescription)) +
Environment.NewLine + Environment.NewLine +
"Creates" + Environment.NewLine +
"===========" + Environment.NewLine +
string.Join(Environment.NewLine, OutputItems.Select(i => i.QuantityItemDescription));
public Recipe(int id, string name, List<ItemQuantity> ingredients, List<ItemQuantity> outputItems)
{
ID = id;
Name = name;
Ingredients = ingredients;
OutputItems = outputItems;
}
}
}
Step 7: Modify Engine\Factories\RecipeFactory.cs
Modify LoadRecipesFromNodes() to pass in parameters for ingredients and outputItems.
RecipeFactory.cs
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Xml;
using Engine.Models;
using Engine.Shared;
namespace Engine.Factories
{
public static class RecipeFactory
{
private const string GAME_DATA_FILENAME = ".\\GameData\\Recipes.xml";
private static readonly List<Recipe> _recipes = new List<Recipe>();
static RecipeFactory()
{
if(File.Exists(GAME_DATA_FILENAME))
{
XmlDocument data = new XmlDocument();
data.LoadXml(File.ReadAllText(GAME_DATA_FILENAME));
LoadRecipesFromNodes(data.SelectNodes("/Recipes/Recipe"));
}
else
{
throw new FileNotFoundException($"Missing data file: {GAME_DATA_FILENAME}");
}
}
private static void LoadRecipesFromNodes(XmlNodeList nodes)
{
foreach(XmlNode node in nodes)
{
var ingredients = new List<ItemQuantity>();
foreach(XmlNode childNode in node.SelectNodes("./Ingredients/Item"))
{
GameItem item = ItemFactory.CreateGameItem(childNode.AttributeAsInt("ID"));
ingredients.Add(new ItemQuantity(item, childNode.AttributeAsInt("Quantity")));
}
var outputItems = new List<ItemQuantity>();
foreach (XmlNode childNode in node.SelectNodes("./OutputItems/Item"))
{
GameItem item = ItemFactory.CreateGameItem(childNode.AttributeAsInt("ID"));
outputItems.Add(new ItemQuantity(item, childNode.AttributeAsInt("Quantity")));
}
Recipe recipe =
new Recipe(node.AttributeAsInt("ID"),
node.SelectSingleNode("./Name")?.InnerText ?? "",
ingredients, outputItems);
_recipes.Add(recipe);
}
}
public static Recipe RecipeByID(int id)
{
return _recipes.FirstOrDefault(x => x.ID == id);
}
}
}
Step 8: Modify Engine\ViewModels\GameSession.cs
Line 282, change to use ItemQuantity.Name value, instead of using the ItemFactory.
GameSession.cs
using System.ComponentModel;
using System.Linq;
using Engine.Factories;
using Engine.Models;
using Engine.Services;
using Newtonsoft.Json;
namespace Engine.ViewModels
{
public class GameSession : INotifyPropertyChanged
{
private readonly MessageBroker _messageBroker = MessageBroker.GetInstance();
#region Properties
private Player _currentPlayer;
private Location _currentLocation;
private Battle _currentBattle;
private Monster _currentMonster;
public event PropertyChangedEventHandler PropertyChanged;
[JsonIgnore]
public GameDetails GameDetails { get; private set; }
[JsonIgnore]
public World CurrentWorld { get; }
public Player CurrentPlayer
{
get => _currentPlayer;
set
{
if(_currentPlayer != null)
{
_currentPlayer.OnLeveledUp -= OnCurrentPlayerLeveledUp;
_currentPlayer.OnKilled -= OnPlayerKilled;
}
_currentPlayer = value;
if(_currentPlayer != null)
{
_currentPlayer.OnLeveledUp += OnCurrentPlayerLeveledUp;
_currentPlayer.OnKilled += OnPlayerKilled;
}
}
}
public Location CurrentLocation
{
get => _currentLocation;
set
{
_currentLocation = value;
CompleteQuestsAtLocation();
GivePlayerQuestsAtLocation();
CurrentMonster = CurrentLocation.GetMonster();
CurrentTrader = CurrentLocation.TraderHere;
}
}
[JsonIgnore]
public Monster CurrentMonster
{
get => _currentMonster;
set
{
if(_currentBattle != null)
{
_currentBattle.OnCombatVictory -= OnCurrentMonsterKilled;
_currentBattle.Dispose();
_currentBattle = null;
}
_currentMonster = value;
if(_currentMonster != null)
{
_currentBattle = new Battle(CurrentPlayer, CurrentMonster);
_currentBattle.OnCombatVictory += OnCurrentMonsterKilled;
}
}
}
[JsonIgnore]
public Trader CurrentTrader { get; private set; }
[JsonIgnore]
public bool HasLocationToNorth =>
CurrentWorld.LocationAt(CurrentLocation.XCoordinate, CurrentLocation.YCoordinate + 1) != null;
[JsonIgnore]
public bool HasLocationToEast =>
CurrentWorld.LocationAt(CurrentLocation.XCoordinate + 1, CurrentLocation.YCoordinate) != null;
[JsonIgnore]
public bool HasLocationToSouth =>
CurrentWorld.LocationAt(CurrentLocation.XCoordinate, CurrentLocation.YCoordinate - 1) != null;
[JsonIgnore]
public bool HasLocationToWest =>
CurrentWorld.LocationAt(CurrentLocation.XCoordinate - 1, CurrentLocation.YCoordinate) != null;
[JsonIgnore]
public bool HasMonster => CurrentMonster != null;
[JsonIgnore]
public bool HasTrader => CurrentTrader != null;
#endregion
public GameSession(Player player, int xCoordinate, int yCoordinate)
{
PopulateGameDetails();
CurrentWorld = WorldFactory.CreateWorld();
CurrentPlayer = player;
CurrentLocation = CurrentWorld.LocationAt(xCoordinate, yCoordinate);
}
public void MoveNorth()
{
if(HasLocationToNorth)
{
CurrentLocation = CurrentWorld.LocationAt(CurrentLocation.XCoordinate, CurrentLocation.YCoordinate + 1);
}
}
public void MoveEast()
{
if(HasLocationToEast)
{
CurrentLocation = CurrentWorld.LocationAt(CurrentLocation.XCoordinate + 1, CurrentLocation.YCoordinate);
}
}
public void MoveSouth()
{
if(HasLocationToSouth)
{
CurrentLocation = CurrentWorld.LocationAt(CurrentLocation.XCoordinate, CurrentLocation.YCoordinate - 1);
}
}
public void MoveWest()
{
if(HasLocationToWest)
{
CurrentLocation = CurrentWorld.LocationAt(CurrentLocation.XCoordinate - 1, CurrentLocation.YCoordinate);
}
}
private void PopulateGameDetails()
{
GameDetails = GameDetailsService.ReadGameDetails();
}
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))
{
CurrentPlayer.RemoveItemsFromInventory(quest.ItemsToComplete);
_messageBroker.RaiseMessage("");
_messageBroker.RaiseMessage($"You completed the '{quest.Name}' quest");
// Give the player the quest rewards
_messageBroker.RaiseMessage($"You receive {quest.RewardExperiencePoints} experience points");
CurrentPlayer.AddExperience(quest.RewardExperiencePoints);
_messageBroker.RaiseMessage($"You receive {quest.RewardGold} gold");
CurrentPlayer.ReceiveGold(quest.RewardGold);
foreach(ItemQuantity itemQuantity in quest.RewardItems)
{
GameItem rewardItem = ItemFactory.CreateGameItem(itemQuantity.ItemID);
_messageBroker.RaiseMessage($"You receive a {rewardItem.Name}");
CurrentPlayer.AddItemToInventory(rewardItem);
}
// Mark the Quest as completed
questToComplete.IsCompleted = true;
}
}
}
}
private void GivePlayerQuestsAtLocation()
{
foreach(Quest quest in CurrentLocation.QuestsAvailableHere)
{
if(!CurrentPlayer.Quests.Any(q => q.PlayerQuest.ID == quest.ID))
{
CurrentPlayer.Quests.Add(new QuestStatus(quest));
_messageBroker.RaiseMessage("");
_messageBroker.RaiseMessage($"You receive the '{quest.Name}' quest");
_messageBroker.RaiseMessage(quest.Description);
_messageBroker.RaiseMessage("Return with:");
foreach(ItemQuantity itemQuantity in quest.ItemsToComplete)
{
_messageBroker
.RaiseMessage($" {itemQuantity.Quantity} {ItemFactory.CreateGameItem(itemQuantity.ItemID).Name}");
}
_messageBroker.RaiseMessage("And you will receive:");
_messageBroker.RaiseMessage($" {quest.RewardExperiencePoints} experience points");
_messageBroker.RaiseMessage($" {quest.RewardGold} gold");
foreach(ItemQuantity itemQuantity in quest.RewardItems)
{
_messageBroker
.RaiseMessage($" {itemQuantity.Quantity} {ItemFactory.CreateGameItem(itemQuantity.ItemID).Name}");
}
}
}
}
public void AttackCurrentMonster()
{
_currentBattle?.AttackOpponent();
}
public void UseCurrentConsumable()
{
if(CurrentPlayer.CurrentConsumable != null)
{
if (_currentBattle == null)
{
CurrentPlayer.OnActionPerformed += OnConsumableActionPerformed;
}
CurrentPlayer.UseCurrentConsumable();
if (_currentBattle == null)
{
CurrentPlayer.OnActionPerformed -= OnConsumableActionPerformed;
}
}
}
private void OnConsumableActionPerformed(object sender, string result)
{
_messageBroker.RaiseMessage(result);
}
public void CraftItemUsing(Recipe recipe)
{
if(CurrentPlayer.Inventory.HasAllTheseItems(recipe.Ingredients))
{
CurrentPlayer.RemoveItemsFromInventory(recipe.Ingredients);
foreach(ItemQuantity itemQuantity in recipe.OutputItems)
{
for(int i = 0; i < itemQuantity.Quantity; i++)
{
GameItem outputItem = ItemFactory.CreateGameItem(itemQuantity.ItemID);
CurrentPlayer.AddItemToInventory(outputItem);
_messageBroker.RaiseMessage($"You craft 1 {outputItem.Name}");
}
}
}
else
{
_messageBroker.RaiseMessage("You do not have the required ingredients:");
foreach(ItemQuantity itemQuantity in recipe.Ingredients)
{
_messageBroker
.RaiseMessage($" {itemQuantity.QuantityItemDescription}");
}
}
}
private void OnPlayerKilled(object sender, System.EventArgs e)
{
_messageBroker.RaiseMessage("");
_messageBroker.RaiseMessage("You have been killed.");
CurrentLocation = CurrentWorld.LocationAt(0, -1);
CurrentPlayer.CompletelyHeal();
}
private void OnCurrentMonsterKilled(object sender, System.EventArgs eventArgs)
{
// Get another monster to fight
CurrentMonster = CurrentLocation.GetMonster();
}
private void OnCurrentPlayerLeveledUp(object sender, System.EventArgs eventArgs)
{
_messageBroker.RaiseMessage($"You are now level {CurrentPlayer.Level}!");
}
}
}
Step 9: Modify Engine\Factories\ItemFactory.cs
Delete ItemName() function (lines 39-42).
ItemFactory.cs
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Xml;
using Engine.Actions;
using Engine.Models;
using Engine.Shared;
namespace Engine.Factories
{
public static class ItemFactory
{
private const string GAME_DATA_FILENAME = ".\\GameData\\GameItems.xml";
private static readonly List<GameItem> _standardGameItems = new List<GameItem>();
static ItemFactory()
{
if(File.Exists(GAME_DATA_FILENAME))
{
XmlDocument data = new XmlDocument();
data.LoadXml(File.ReadAllText(GAME_DATA_FILENAME));
LoadItemsFromNodes(data.SelectNodes("/GameItems/Weapons/Weapon"));
LoadItemsFromNodes(data.SelectNodes("/GameItems/HealingItems/HealingItem"));
LoadItemsFromNodes(data.SelectNodes("/GameItems/MiscellaneousItems/MiscellaneousItem"));
}
else
{
throw new FileNotFoundException($"Missing data file: {GAME_DATA_FILENAME}");
}
}
public static GameItem CreateGameItem(int itemTypeID)
{
return _standardGameItems.FirstOrDefault(item => item.ItemTypeID == itemTypeID)?.Clone();
}
private static void LoadItemsFromNodes(XmlNodeList nodes)
{
if(nodes == null)
{
return;
}
foreach(XmlNode node in nodes)
{
GameItem.ItemCategory itemCategory = DetermineItemCategory(node.Name);
GameItem gameItem =
new GameItem(itemCategory,
node.AttributeAsInt("ID"),
node.AttributeAsString("Name"),
node.AttributeAsInt("Price"),
itemCategory == GameItem.ItemCategory.Weapon);
if(itemCategory == GameItem.ItemCategory.Weapon)
{
gameItem.Action =
new AttackWithWeapon(gameItem, node.AttributeAsString("DamageDice"));
}
else if(itemCategory == GameItem.ItemCategory.Consumable)
{
gameItem.Action =
new Heal(gameItem,
node.AttributeAsInt("HitPointsToHeal"));
}
_standardGameItems.Add(gameItem);
}
}
private static GameItem.ItemCategory DetermineItemCategory(string itemType)
{
switch(itemType)
{
case "Weapon":
return GameItem.ItemCategory.Weapon;
case "HealingItem":
return GameItem.ItemCategory.Consumable;
default:
return GameItem.ItemCategory.Miscellaneous;
}
}
}
}
Step 10: Test the game
NEXT LESSON: Lesson 19.8: Create SOSCSRPG.Core project for native language extensions
PREVIOUS LESSON: Lesson 19.6: Move files out of Engine project