Lesson 18.1: Making Configurable GameDetails

Now it’s time to enhance our Player class.

I’ve been worried about how to do this so it’s extremely customizable. If you’re creating a traditional medieval game, you might add attributes like Strength, Wisdom, Intelligence, Dexterity, Constitution, and Charisma. But a player in a science fiction game might have different attributes.

The method I’m going to try will probably add a little complexity. We’ll try to clean it up (refactor it), once we have the code working.

 

We’ll start by creating a JSON file to hold information about the game, like the attributes we want the Player class to have. This file will also include the game name and version – plus more things in the future.

 

 

 

Lesson Steps

Step 1: Create \Engine\Models\PlayerAttribute.cs

This new class is where we’ll hold the Player’s attributes.

The “Key” property is how we’ll find the specific attribute in the list of PlayerAttributes. This will be populated with values like “STR” for strength. DisplayName is what we’ll display in the UI “Strength”. DiceNotation holds the information we need to “roll” for the attribute’s value.

BaseValue will hold the Player’s normal value, and ModifiedValue will hold the BaseValue plus any affects (like a curse, spell, enchantment, etc.)

Notice that we have three constructors, two that only call other versions of the constructor. This is called “chaining constructors”.

If we call the constructor with three parameters, it will call the constructor with four parameters, passing in a random number for the fourth parameter. The four-parameter constructor will call the five-parameter version, passing in that random number for both the baseValue and modifiedValue parameters.

This chaining will be useful when we create a new Player and give them new values. But the five-parameter constructor will be used when we load a Player from a saved game.

The ReRoll function will be used in our upcoming Player creation screen. If the user isn’t happy with their Player’s random attributes, we’ll use this function to roll a new set of values.

 

PlayerAttribute.cs

using Engine.Services;

namespace Engine.Models
{
    public class PlayerAttribute
    {
        public string Key { get; }
        public string DisplayName { get; }
        public string DiceNotation { get; }
        public int BaseValue { get; set; }
        public int ModifiedValue { get; set; }

        // Constructor that will use DiceService to create a BaseValue.
        // The constructor this calls will put that same value into BaseValue and ModifiedValue
        public PlayerAttribute(string key, string displayName, string diceNotation)
            : this(key, displayName, diceNotation, DiceService.Instance.Roll(diceNotation).Value)
        {
        }

        // Constructor that takes a baseValue and also uses it for modifiedValue,
        // for when we're creating a new attribute
        public PlayerAttribute(string key, string displayName, string diceNotation,
                               int baseValue) :
            this(key, displayName, diceNotation, baseValue, baseValue)
        {
        }

        // This constructor is eventually called by the others, 
        // or used when reading a Player's attributes from a saved game file.
        public PlayerAttribute(string key, string displayName, string diceNotation,
                               int baseValue, int modifiedValue)
        {
            Key = key;
            DisplayName = displayName;
            DiceNotation = diceNotation;
            BaseValue = baseValue;
            ModifiedValue = modifiedValue;
        }

        public void ReRoll()
        {
            BaseValue = DiceService.Instance.Roll(DiceNotation).Value;
            ModifiedValue = BaseValue;
        }
    }
}

 

Step 2: Create \Engine\GameData\GameDetails.json

This file will hold the data for your specific game. The idea is that you’ll be able to create a new game without changing any source code – just the data files.

This is a JSON file, instead of an XML file, like our other game data files. JSON has become more popular than XML, and we’ll eventually switch the old XML files into JSON.

Right now, we’re going to store the game’s name, version number, and list of PlayerAttributes (what we’re working on right now).

Notice that we only have values for Key, DisplayName, and DiceNotation. Because of the chained constructors in PlayerAttribute, we’ll end up creating values for BaseValue and ModifiedValue. But we can ignore these until we use the game’s PlayerAttributes to instantiate a new Player.

 

Remember to set this file’s properties to:

Build Action = Content

Copy to Output Directory = Copy Always

 

GameDetails.json

{
  "Name": "SOSCSRPG",
  "Version": "0.1.000",
  "PlayerAttributes": [
    {
      "Key": "STR",
      "DisplayName": "Strength",
      "DiceNotation": "3d6"
    },
    {
      "Key": "INT",
      "DisplayName": "Intelligence",
      "DiceNotation": "3d6"
    },
    {
      "Key": "WIS",
      "DisplayName": "Wisdom",
      "DiceNotation": "3d6"
    },
    {
      "Key": "CON",
      "DisplayName": "Constitution",
      "DiceNotation": "3d6"
    },
    {
      "Key": "DEX",
      "DisplayName": "Dexterity",
      "DiceNotation": "3d6"
    },
    {
      "Key": "CHA",
      "DisplayName": "Charisma",
      "DiceNotation": "3d6"
    }
  ]
}

 

Step 3: Create \Engine\Models\GameDetails.cs

This is a simple model to hold the deserialized data from GameDetails.json, so we can use that data in the game.

 

GameDetails.cs

using System.Collections.Generic;

namespace Engine.Models
{
    public class GameDetails
    {
        public string Name { get; set; }
        public string Version { get; set; }

        public List<PlayerAttribute> PlayerAttributes { get; set; } =
            new List<PlayerAttribute>();

        public GameDetails(string name, string version)
        {
            Name = name;
            Version = version;
        }
    }
}

 

Step 4: Change \Engine\ViewModels\GameSession.cs

We’re going to hold an instance of the new GameDetails object in the GameSession class, so we can use its values.

We add a new GameDetails property on lines 25-34, with its backing variable on line 17. Notice that we have a JsonIgnore attribute for this property. We don’t want to save this data in the saved game files. We’ll always get it from GameDetails.json.

On lines 210-224, we add a new function to read the GameDetails.json file and populate the GameDetails property. We have to do this manual parsing and instantiation, instead of just using Newonsoft’s DeserializeObject because of the chained constructors in the PlayerAttribute class. There are ways to work around that, but I chose to keep the GameDetails class clean and handle creating the GameDetails object this way.

We’ll almost-certainly move this logic somewhere else in the future. It doesn’t belong in the GameSession class. But this is good for now.

 

Now that we have the PopulateGameDetails function, we’ll add calls to it in the GameSession constructors at lines 147 and 171.

 

GameSession.cs

using System.IO;
using System.Linq;
using Engine.Factories;
using Engine.Models;
using Engine.Services;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;

namespace Engine.ViewModels
{
    public class GameSession : BaseNotificationClass
    {
        private readonly MessageBroker _messageBroker = MessageBroker.GetInstance();

        #region Properties

        private GameDetails _gameDetails;

        private Player _currentPlayer;
        private Location _currentLocation;
        private Battle _currentBattle;
        private Monster _currentMonster;
        private Trader _currentTrader;

        [JsonIgnore]
        public GameDetails GameDetails
        {
            get => _gameDetails;
            set
            {
                _gameDetails = value;
                OnPropertyChanged();
            }
        }

        [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;

                OnPropertyChanged();
                OnPropertyChanged(nameof(HasLocationToNorth));
                OnPropertyChanged(nameof(HasLocationToEast));
                OnPropertyChanged(nameof(HasLocationToWest));
                OnPropertyChanged(nameof(HasLocationToSouth));

                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;
                }

                OnPropertyChanged();
                OnPropertyChanged(nameof(HasMonster));
            }
        }

        [JsonIgnore]
        public Trader CurrentTrader
        {
            get => _currentTrader;
            set
            {
                _currentTrader = value;

                OnPropertyChanged();
                OnPropertyChanged(nameof(HasTrader));
            }
        }

        [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()
        {
            PopulateGameDetails();

            CurrentWorld = WorldFactory.CreateWorld();

            int dexterity = DiceService.Instance.Roll(6, 3).Value;

            CurrentPlayer = new Player("Scott", "Fighter", 0, 10, 10, dexterity, 1000000);

            if (!CurrentPlayer.Inventory.Weapons.Any())
            {
                CurrentPlayer.AddItemToInventory(ItemFactory.CreateGameItem(1001));
            }

            CurrentPlayer.AddItemToInventory(ItemFactory.CreateGameItem(2001));
            CurrentPlayer.LearnRecipe(RecipeFactory.RecipeByID(1));
            CurrentPlayer.AddItemToInventory(ItemFactory.CreateGameItem(3001));
            CurrentPlayer.AddItemToInventory(ItemFactory.CreateGameItem(3002));
            CurrentPlayer.AddItemToInventory(ItemFactory.CreateGameItem(3003));

            CurrentLocation = CurrentWorld.LocationAt(0, 0);
        }

        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()
        {
            JObject gameDetails = 
                JObject.Parse(File.ReadAllText(".\\GameData\\GameDetails.json"));

            GameDetails = new GameDetails(gameDetails["Name"].ToString(), 
                                          gameDetails["Version"].ToString());

            foreach(JToken token in gameDetails["PlayerAttributes"])
            {
                GameDetails.PlayerAttributes.Add(new PlayerAttribute(token["Key"].ToString(),
                                                                     token["DisplayName"].ToString(),
                                                                     token["DiceNotation"].ToString()));
            }
        }

        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.Quantity} {ItemFactory.ItemName(itemQuantity.ItemID)}");
                }
            }
        }

        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 5: Change \WPFUI\MainWindow.xaml

Now that we can get the game’s name from our deserialized GameDetails.json file, we can use databinding to display the game’s name in the title bar.

Change line 10 (the window’s Title attribute”) to get the value from “{Binding GameDetails.Name}”

 

MainWindow.xaml (first 12 lines)

<Window x:Class="WPFUI.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:viewModels="clr-namespace:Engine.ViewModels;assembly=Engine"
        d:DataContext="{d:DesignInstance viewModels:GameSession}"
        mc:Ignorable="d"
        FontSize="11pt"
        Title="{Binding GameDetails.Name}" Height="768" Width="1024"
        KeyDown="MainWindow_OnKeyDown"
        Closing="MainWindow_OnClosing">

 

Step 6: Change \TestEngine\Services\TestSaveGameService.cs

We used to populate a Version property in the GameSession class. Now we will get that value from GameSession’s GameDetails.Version property. So, we need to update that in our unit test.

Change the Assert on line 19 to get the version value from “GameSession.GameDetails.Version”.

 

TestSaveGameService.cs

using System.Linq;
using Engine.Services;
using Engine.ViewModels;
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace TestEngine.Services
{
    [TestClass]
    public class TestSaveGameService
    {
        [TestMethod]
        public void Test_Restore_0_1_000()
        {
            GameSession gameSession = 
                SaveGameService
                    .LoadLastSaveOrCreateNew(@".\TestFiles\SavedGames\Game_0_1_000.soscsrpg");

            // Game session data
            Assert.AreEqual("0.1.000", gameSession.GameDetails.Version);
            Assert.AreEqual(0, gameSession.CurrentLocation.XCoordinate);
            Assert.AreEqual(1, gameSession.CurrentLocation.YCoordinate);

            // Player data
            Assert.AreEqual("Fighter", gameSession.CurrentPlayer.CharacterClass);
            Assert.AreEqual("Scott", gameSession.CurrentPlayer.Name);
            Assert.AreEqual(18, gameSession.CurrentPlayer.Dexterity);
            Assert.AreEqual(8, gameSession.CurrentPlayer.CurrentHitPoints);
            Assert.AreEqual(10, gameSession.CurrentPlayer.MaximumHitPoints);
            Assert.AreEqual(20, gameSession.CurrentPlayer.ExperiencePoints);
            Assert.AreEqual(1, gameSession.CurrentPlayer.Level);
            Assert.AreEqual(1000000, gameSession.CurrentPlayer.Gold);

            // Player quest data
            Assert.AreEqual(1, gameSession.CurrentPlayer.Quests.Count);
            Assert.AreEqual(1, gameSession.CurrentPlayer.Quests[0].PlayerQuest.ID);
            Assert.IsFalse(gameSession.CurrentPlayer.Quests[0].IsCompleted);

            // Player recipe data
            Assert.AreEqual(1, gameSession.CurrentPlayer.Recipes.Count);
            Assert.AreEqual(1, gameSession.CurrentPlayer.Recipes[0].ID);

            // Player inventory data
            Assert.AreEqual(5, gameSession.CurrentPlayer.Inventory.Items.Count);
            Assert.AreEqual(1, gameSession.CurrentPlayer.Inventory.Items.Count(i => i.ItemTypeID.Equals(1001)));
            Assert.AreEqual(1, gameSession.CurrentPlayer.Inventory.Items.Count(i => i.ItemTypeID.Equals(2001)));
            Assert.AreEqual(1, gameSession.CurrentPlayer.Inventory.Items.Count(i => i.ItemTypeID.Equals(3001)));
            Assert.AreEqual(1, gameSession.CurrentPlayer.Inventory.Items.Count(i => i.ItemTypeID.Equals(3002)));
            Assert.AreEqual(1, gameSession.CurrentPlayer.Inventory.Items.Count(i => i.ItemTypeID.Equals(3003)));
        }
    }
}

 

Step 7: Change \Engine\Services\SaveGameService.cs

We used to get the name for the Version property from GameSession.Version. Since we deleted that property, we need to change line 152 to use GameSession.GameDetails.Version.

 

SaveGameService.cs

        private static string FileVersion(JObject data)
        {
            return (string)data[nameof(GameSession.GameDetails.Version)];
        }

 

Step 9: Change \Engine\Models\Player.cs

While working on these changes, I did a little cleanup in the Player class.

For the Quests and Recipes properties (lines 37 and 40), I set them to empty ObservableCollections in the property declaration, instead of inside the constructor. This doesn’t change anything about how the program runs. I just like this format a little better.

 

Player.cs

using System;
using System.Collections.ObjectModel;
using System.Linq;

namespace Engine.Models
{
    public class Player : LivingEntity
    {
        #region Properties

        private string _characterClass;
        private int _experiencePoints;

        public string CharacterClass
        {
            get => _characterClass;
            set
            {
                _characterClass = value;
                OnPropertyChanged();
            }
        }

        public int ExperiencePoints
        {
            get => _experiencePoints;
            private set
            {
                _experiencePoints = value;

                OnPropertyChanged();

                SetLevelAndMaximumHitPoints();
            }
        }

        public ObservableCollection<QuestStatus> Quests { get; } =
            new ObservableCollection<QuestStatus>();

        public ObservableCollection<Recipe> Recipes { get; } = 
            new ObservableCollection<Recipe>();

        #endregion

        public event EventHandler OnLeveledUp;

        public Player(string name, string characterClass, int experiencePoints,
                      int maximumHitPoints, int currentHitPoints, int dexterity, int gold) : 
            base(name, maximumHitPoints, currentHitPoints, dexterity, gold)
        {
            CharacterClass = characterClass;
            ExperiencePoints = experiencePoints;
        }

        public void AddExperience(int experiencePoints)
        {
            ExperiencePoints += experiencePoints;
        }

        public void LearnRecipe(Recipe recipe)
        {
            if(!Recipes.Any(r => r.ID == recipe.ID))
            {
                Recipes.Add(recipe);
            }
        }

        private void SetLevelAndMaximumHitPoints()
        {
            int originalLevel = Level;

            Level = (ExperiencePoints / 100) + 1;

            if (Level != originalLevel)
            {
                MaximumHitPoints = Level * 10;

                OnLeveledUp?.Invoke(this, System.EventArgs.Empty);
            }
        }
    }
}

 

Step 9: Test the game

Run the unit tests, verify that they all still pass, and run the game. You can change the data in GameDetails.json, rebuild the solution, and see it have the new name in the title bar.

 

 

Additional links for this project

Source code: https://github.com/ScottLilly/SOSCSRPG

Project plan: https://github.com/ScottLilly/SOSCSRPG/projects/1

Discord: https://discord.gg/AUYXYtH

Return to the main page

Leave a Reply

Your email address will not be published. Required fields are marked *