Lesson 17.1: Saving and loading game state

We have enough features in the game that it could be worth playing – especially if you made your own world larger by adding your own monsters, locations, quests, and game items.

So, to make the game more playable, we will add the ability to save and load the game.

We currently save the game data in XML files, but I switched to JSON files for the saved game data. JSON is more popular than XML nowadays, and it’s something you’ll probably see in other programs – especially if you work with REST APIs.

 

 

 

 

Lesson Steps

Step 1: Create \Engine\Services\SaveGameService.cs

The first thing we’ll create is the SaveGameService, to save and load the game to disk.

The format is JSON (JavaScript Object Notation), which is like XML. I have a sample saved game below, so you can see what JSON looks like.

The main differences from XML are changing XML nodes and values to use key/value pairs, using curly braces instead of brackets, and not using closing nodes (since the closing curly brace identifies the end of an object).

 

In order to work with JSON, I included a NuGet package named Newtonsoft.Json. A NuGet package is a library of code, like a DLL you’d create from your own projects. In fact, you can create your own NuGet packages if you have some re-usable code you want to share (even if only with yourself). Using NuGet package manager, you can add a library to your projects, and to keep them updated as new versions are released.

 

To add a NuGet package, right-click on your solution in Solution Explorer and select “Manage NuGet Packages for Solution…”. That will display a screen where you can search for packages online and manage the packages in your solution.

Click on the “Browse” link and type “newtonsoft.json” into the search textbox. Left-click on the Newtonsoft.Json package. On the right side of the screen, you’ll see a list of your projects. Select the “Engine” project to add Newtonsoft to, select the most recent version from the dropdown, and click the “Install” button. This adds the package to your project. Now we can use the Newtonsoft JSON functions in our project.

 

NuGet Package Manage screen in Visual Studio

 

Now we can get to the SaveGameService code.

Notice the “using” directives on lines 1-7. We need “Sytem.IO” to read and write files, and we need two Newtonsoft.Json namespaces, so we can use the JSON classes and functions.

On line 13 we have a constant to store the saved game’s file name. In the future, we could expand this to allow multiple saves. But we’ll just have one saved game for now.

Lines 15-19 are the Save function. We pass the GameSession object into the function and serialize it into a JSON string with:

“JsonConvert.SerializeObject(gameSession, Formatting.Indented)”

This function looks at the properties of the gameSession object (and, if the property is a class, that property’s properties) and converts them to a string. The File.WriteAllText function writes that string to the disk.

Lines 21-48 is the function that loads the last saved game if one exists. If a saved game file doesn’t exist, the function will return a new GameSession object instead.

If the file existed, line 31 reads it from disk and puts the JSON into a JObject – an object we can query against to get values.

Line 34 passes the JObject to the CreatePlayer function, which will call all the other code so we can populate the GameSession object with the player data.

On lines 36 and 37 we get the last saved game’s location X and Y values. We get the values like you would get a value from a multi-dimensional array – except, instead of using numbers for the indexes, we use the names of the properties.

Notice we always use nameof() to get the property name. This way, if we change a property name in the future, either the property name here will change (if we use a refactoring tool to rename the property) or give an error – which we should quickly notice.

On line 40 we instantiate a new GameSession object with the data we read from the saved game file. This uses a new GameSession constructor we’ll create later.

On lines 42-47, if there was an error while parsing the saved game file, we create a new empty GameSession object and return that.

Lines 50-149 are the functions to parse the data in the saved game file.

Something to notice is that they all get the file version from the saved game file before doing the parsing. This is because we will probably change the properties in GameSession and its properties’ classes. Since different versions of the saved game file will have different formats, they’ll need to have different parsers.

Other than that, we basically parse the values from the file and used them to construct the other objects we need – Player, GameItems, Quests, and Recipes.

The final function on line 152-155 gets the version of the saved game file.

 

SOSCSRPG.json

{
  "Version": "0.1.000",
  "CurrentWorld": {},
  "CurrentPlayer": {
    "CharacterClass": "Fighter",
    "ExperiencePoints": 5,
    "Quests": [
      {
        "PlayerQuest": {
          "ID": 1,
          "Name": "Clear the herb garden",
          "Description": "Defeat the snakes in the Herbalist's garden.",
          "ItemsToComplete": [
            {
              "ItemID": 9001,
              "Quantity": 5,
              "QuantityItemDescription": "5 Snake fang"
            }
          ],
          "RewardExperiencePoints": 25,
          "RewardGold": 10,
          "RewardItems": [
            {
              "ItemID": 1002,
              "Quantity": 1,
              "QuantityItemDescription": "1 Rusty sword"
            }
          ],
          "ToolTipContents": "Defeat the snakes in the Herbalist's garden.\r\n\r\nItems to complete the quest\r\n===========================\r\n5 Snake fang\r\n\r\nRewards\r\n===========================\r\n25 experience points\r\n10 gold pieces\r\n1 Rusty sword"
        },
        "IsCompleted": false
      }
    ],
    "Recipes": [
      {
        "ID": 1,
        "Name": "Granola bar",
        "Ingredients": [
          {
            "ItemID": 3001,
            "Quantity": 1,
            "QuantityItemDescription": "1 Oats"
          },
          {
            "ItemID": 3002,
            "Quantity": 1,
            "QuantityItemDescription": "1 Honey"
          },
          {
            "ItemID": 3003,
            "Quantity": 1,
            "QuantityItemDescription": "1 Raisins"
          }
        ],
        "OutputItems": [
          {
            "ItemID": 2001,
            "Quantity": 1,
            "QuantityItemDescription": "1 Granola bar"
          }
        ],
        "ToolTipContents": "Ingredients\r\n===========\r\n1 Oats\r\n1 Honey\r\n1 Raisins\r\n\r\nCreates\r\n===========\r\n1 Granola bar"
      }
    ],
    "Name": "Scott",
    "Dexterity": 15,
    "CurrentHitPoints": 10,
    "MaximumHitPoints": 10,
    "Gold": 2000003,
    "Level": 1,
    "Inventory": {
      "Items": [
        {
          "Category": 1,
          "ItemTypeID": 1001,
          "Name": "Pointy stick",
          "Price": 1,
          "IsUnique": true,
          "Action": {}
        },
        {
          "Category": 2,
          "ItemTypeID": 2001,
          "Name": "Granola bar",
          "Price": 5,
          "IsUnique": false,
          "Action": {}
        },
        {
          "Category": 0,
          "ItemTypeID": 3001,
          "Name": "Oats",
          "Price": 1,
          "IsUnique": false,
          "Action": null
        },
        {
          "Category": 0,
          "ItemTypeID": 3002,
          "Name": "Honey",
          "Price": 2,
          "IsUnique": false,
          "Action": null
        },
        {
          "Category": 0,
          "ItemTypeID": 3003,
          "Name": "Raisins",
          "Price": 2,
          "IsUnique": false,
          "Action": null
        }
      ],
      "GroupedInventory": [
        {
          "Item": {
            "Category": 1,
            "ItemTypeID": 1001,
            "Name": "Pointy stick",
            "Price": 1,
            "IsUnique": true,
            "Action": {}
          },
          "Quantity": 1
        },
        {
          "Item": {
            "Category": 2,
            "ItemTypeID": 2001,
            "Name": "Granola bar",
            "Price": 5,
            "IsUnique": false,
            "Action": {}
          },
          "Quantity": 1
        },
        {
          "Item": {
            "Category": 0,
            "ItemTypeID": 3001,
            "Name": "Oats",
            "Price": 1,
            "IsUnique": false,
            "Action": null
          },
          "Quantity": 1
        },
        {
          "Item": {
            "Category": 0,
            "ItemTypeID": 3002,
            "Name": "Honey",
            "Price": 2,
            "IsUnique": false,
            "Action": null
          },
          "Quantity": 1
        },
        {
          "Item": {
            "Category": 0,
            "ItemTypeID": 3003,
            "Name": "Raisins",
            "Price": 2,
            "IsUnique": false,
            "Action": null
          },
          "Quantity": 1
        }
      ],
      "Weapons": [
        {
          "Category": 1,
          "ItemTypeID": 1001,
          "Name": "Pointy stick",
          "Price": 1,
          "IsUnique": true,
          "Action": {}
        }
      ],
      "Consumables": [
        {
          "Category": 2,
          "ItemTypeID": 2001,
          "Name": "Granola bar",
          "Price": 5,
          "IsUnique": false,
          "Action": {}
        }
      ],
      "HasConsumable": true
    },
    "CurrentWeapon": null,
    "CurrentConsumable": null,
    "IsAlive": true,
    "IsDead": false
  },
  "CurrentLocation": {
    "XCoordinate": 0,
    "YCoordinate": 0,
    "Name": "Town Square",
    "Description": "You see a fountain here.",
    "ImageName": "./Images/Locations/TownSquare.png",
    "QuestsAvailableHere": [],
    "MonstersHere": [],
    "TraderHere": null
  },
  "CurrentMonster": null,
  "CurrentTrader": null,
  "HasLocationToNorth": true,
  "HasLocationToEast": true,
  "HasLocationToSouth": true,
  "HasLocationToWest": true,
  "HasMonster": false,
  "HasTrader": false
}

 

SaveGameService.cs

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

namespace Engine.Services
{
    public static class SaveGameService
    {
        private const string SAVE_GAME_FILE_NAME = "SOSCRPG.json";

        public static void Save(GameSession gameSession)
        {
            File.WriteAllText(SAVE_GAME_FILE_NAME,
                              JsonConvert.SerializeObject(gameSession, Formatting.Indented));
        }

        public static GameSession LoadLastSaveOrCreateNew()
        {
            if(!File.Exists(SAVE_GAME_FILE_NAME))
            {
                return new GameSession();
            }

            // Save game file exists, so create the GameSession object from it.
            try
            {
                JObject data = JObject.Parse(File.ReadAllText(SAVE_GAME_FILE_NAME));

                // Populate Player object
                Player player = CreatePlayer(data);

                int x = (int)data[nameof(GameSession.CurrentLocation)][nameof(Location.XCoordinate)];
                int y = (int)data[nameof(GameSession.CurrentLocation)][nameof(Location.YCoordinate)];

                // Create GameSession object with saved game data
                return new GameSession(player, x, y);
            }
            catch(Exception ex)
            {
                // If there was an error loading/deserializing the saved game, 
                // create a brand new GameSession object.
                return new GameSession();
            }
        }

        private static Player CreatePlayer(JObject data)
        {
            string fileVersion = FileVersion(data);

            Player player;

            switch(fileVersion)
            {
                case "0.1.000":
                    player =
                        new Player((string)data[nameof(GameSession.CurrentPlayer)][nameof(Player.Name)],
                                   (string)data[nameof(GameSession.CurrentPlayer)][nameof(Player.CharacterClass)],
                                   (int)data[nameof(GameSession.CurrentPlayer)][nameof(Player.ExperiencePoints)],
                                   (int)data[nameof(GameSession.CurrentPlayer)][nameof(Player.MaximumHitPoints)],
                                   (int)data[nameof(GameSession.CurrentPlayer)][nameof(Player.CurrentHitPoints)],
                                   (int)data[nameof(GameSession.CurrentPlayer)][nameof(Player.Dexterity)],
                                   (int)data[nameof(GameSession.CurrentPlayer)][nameof(Player.Gold)]);
                    break;
                default:
                    throw new InvalidDataException($"File version '{fileVersion}' not recognized");
            }

            PopulatePlayerInventory(data, player);

            PopulatePlayerQuests(data, player);

            PopulatePlayerRecipes(data, player);

            return player;
        }

        private static void PopulatePlayerInventory(JObject data, Player player)
        {
            string fileVersion = FileVersion(data);

            switch(fileVersion)
            {
                case "0.1.000":
                    foreach(JToken itemToken in (JArray)data[nameof(GameSession.CurrentPlayer)]
                        [nameof(Player.Inventory)]
                        [nameof(Inventory.Items)])
                    {
                        int itemId = (int)itemToken[nameof(GameItem.ItemTypeID)];

                        player.AddItemToInventory(ItemFactory.CreateGameItem(itemId));
                    }

                    break;
                default:
                    throw new InvalidDataException($"File version '{fileVersion}' not recognized");
            }
        }

        private static void PopulatePlayerQuests(JObject data, Player player)
        {
            string fileVersion = FileVersion(data);

            switch(fileVersion)
            {
                case "0.1.000":
                    foreach(JToken questToken in (JArray)data[nameof(GameSession.CurrentPlayer)]
                        [nameof(Player.Quests)])
                    {
                        int questId =
                            (int)questToken[nameof(QuestStatus.PlayerQuest)][nameof(QuestStatus.PlayerQuest.ID)];

                        Quest quest = QuestFactory.GetQuestByID(questId);
                        QuestStatus questStatus = new QuestStatus(quest);
                        questStatus.IsCompleted = (bool)questToken[nameof(QuestStatus.IsCompleted)];

                        player.Quests.Add(questStatus);
                    }

                    break;
                default:
                    throw new InvalidDataException($"File version '{fileVersion}' not recognized");
            }
        }

        private static void PopulatePlayerRecipes(JObject data, Player player)
        {
            string fileVersion = FileVersion(data);

            switch(fileVersion)
            {
                case "0.1.000":
                    foreach(JToken recipeToken in
                        (JArray)data[nameof(GameSession.CurrentPlayer)][nameof(Player.Recipes)])
                    {
                        int recipeId = (int)recipeToken[nameof(Recipe.ID)];

                        Recipe recipe = RecipeFactory.RecipeByID(recipeId);

                        player.Recipes.Add(recipe);
                    }

                    break;
                default:
                    throw new InvalidDataException($"File version '{fileVersion}' not recognized");
            }
        }

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

 

Step 2: Modify \Engine\ViewModels\GameSession.cs

There are only a few changes to GameSession.

First, I moved the backing variable _currentBattle from line 12 to 16, so it can be with the other backing variables.

On line 20, there’s a new Version property. It’s a “get”-only function that returns the hard-coded version number. If we change the properties of the GameSession class, we’ll need to update this with a new version number.

Next, we need to create the new GameSession constructor to use when passing in saved game data.

In the original constructor, I moved the line where we populate the CurrentWorld property from line 137 to line 123. It wasn’t required. But it feels more natural to populate the world before we do anything else.

Then, we have the new constructor on lines 143-148. It takes a player and the X and Y coordinates of where to start the player and populates GameSession’s properties.

 

GameSession.cs

using System.Linq;
using Engine.Factories;
using Engine.Models;
using Engine.Services;

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

        #region Properties

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

        public string Version { get; } = "0.1.000";

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

        public Monster CurrentMonster
        {
            get => _currentMonster;
            set
            {
                if(_currentBattle != null)
                {
                    _currentBattle.OnCombatVictory -= OnCurrentMonsterKilled;
                    _currentBattle.Dispose();
                }

                _currentMonster = value;

                if(_currentMonster != null)
                {
                    _currentBattle = new Battle(CurrentPlayer, CurrentMonster);

                    _currentBattle.OnCombatVictory += OnCurrentMonsterKilled;
                }

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

        public Trader CurrentTrader
        {
            get => _currentTrader;
            set
            {
                _currentTrader = value;

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

        public bool HasLocationToNorth =>
            CurrentWorld.LocationAt(CurrentLocation.XCoordinate, CurrentLocation.YCoordinate + 1) != null;

        public bool HasLocationToEast =>
            CurrentWorld.LocationAt(CurrentLocation.XCoordinate + 1, CurrentLocation.YCoordinate) != null;

        public bool HasLocationToSouth =>
            CurrentWorld.LocationAt(CurrentLocation.XCoordinate, CurrentLocation.YCoordinate - 1) != null;

        public bool HasLocationToWest =>
            CurrentWorld.LocationAt(CurrentLocation.XCoordinate - 1, CurrentLocation.YCoordinate) != null;

        public bool HasMonster => CurrentMonster != null;

        public bool HasTrader => CurrentTrader != null;

        #endregion

        public GameSession()
        {
            CurrentWorld = WorldFactory.CreateWorld();

            int dexterity = RandomNumberGenerator.NumberBetween(3, 18);

            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)
        {
            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 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)
            {
                CurrentPlayer.UseCurrentConsumable();
            }
        }

        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 3: Modify \WPFUI\MainWindow.xaml

Now that we have code to save and load a game, we need to modify the UI to use it.

Add the “Closing” attribute on line 12 of the window’s attributes, with the function to run when the window closes.

 

MainWindow.xaml (lines 1 – 12)

<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="Scott's Awesome Game" Height="768" Width="1024"
        KeyDown="MainWindow_OnKeyDown"
        Closing="MainWindow_OnClosing">

 

Step 4: Modify \WPFUI\MainWindow.xaml.cs

On line 3, add “using System.ComponentModel;”, which we need for the Closing event arguments parameter.

Change line 18 to only declare the private GameSession variable, instead of instantiating a new object for it.

In the constructor, on line 30, set “_gameSession” to whatever value is returned from the SaveGameService.LoadLastSaveOrCreateNew function. This is how we load the game from the saved game file (if it exists).

Lines 125-128 is where we have the function for the window’s Closing event, and where we call the function to save the current game to the file.

 

 

MainWindow.xaml.cs

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Documents;
using System.Windows.Input;
using Engine.EventArgs;
using Engine.Models;
using Engine.Services;
using Engine.ViewModels;

namespace WPFUI
{
    public partial class MainWindow : Window
    {
        private readonly MessageBroker _messageBroker = MessageBroker.GetInstance();
        private readonly GameSession _gameSession;
        private readonly Dictionary<Key, Action> _userInputActions = 
            new Dictionary<Key, Action>();

        public MainWindow()
        {
            InitializeComponent();

            InitializeUserInputActions();

            _messageBroker.OnMessageRaised += OnGameMessageRaised;

            _gameSession = SaveGameService.LoadLastSaveOrCreateNew();

            DataContext = _gameSession;
        }

        private void OnClick_MoveNorth(object sender, RoutedEventArgs e)
        {
            _gameSession.MoveNorth();
        }

        private void OnClick_MoveWest(object sender, RoutedEventArgs e)
        {
            _gameSession.MoveWest();
        }

        private void OnClick_MoveEast(object sender, RoutedEventArgs e)
        {
            _gameSession.MoveEast();
        }

        private void OnClick_MoveSouth(object sender, RoutedEventArgs e)
        {
            _gameSession.MoveSouth();
        }

        private void OnClick_AttackMonster(object sender, RoutedEventArgs e)
        {
            _gameSession.AttackCurrentMonster();
        }

        private void OnClick_UseCurrentConsumable(object sender, RoutedEventArgs e)
        {
            _gameSession.UseCurrentConsumable();
        }

        private void OnGameMessageRaised(object sender, GameMessageEventArgs e)
        {
            GameMessages.Document.Blocks.Add(new Paragraph(new Run(e.Message)));
            GameMessages.ScrollToEnd();
        }

        private void OnClick_DisplayTradeScreen(object sender, RoutedEventArgs e)
        {
            if(_gameSession.CurrentTrader != null)
            {
                TradeScreen tradeScreen = new TradeScreen();
                tradeScreen.Owner = this;
                tradeScreen.DataContext = _gameSession;
                tradeScreen.ShowDialog();
            }
        }

        private void OnClick_Craft(object sender, RoutedEventArgs e)
        {
            Recipe recipe = ((FrameworkElement)sender).DataContext as Recipe;
            _gameSession.CraftItemUsing(recipe);
        }

        private void InitializeUserInputActions()
        {
            _userInputActions.Add(Key.W, () => _gameSession.MoveNorth());
            _userInputActions.Add(Key.A, () => _gameSession.MoveWest());
            _userInputActions.Add(Key.S, () => _gameSession.MoveSouth());
            _userInputActions.Add(Key.D, () => _gameSession.MoveEast());
            _userInputActions.Add(Key.Z, () => _gameSession.AttackCurrentMonster());
            _userInputActions.Add(Key.C, () => _gameSession.UseCurrentConsumable());
            _userInputActions.Add(Key.I, () => SetTabFocusTo("InventoryTabItem"));
            _userInputActions.Add(Key.Q, () => SetTabFocusTo("QuestsTabItem"));
            _userInputActions.Add(Key.R, () => SetTabFocusTo("RecipesTabItem"));
            _userInputActions.Add(Key.T, () => OnClick_DisplayTradeScreen(this, new RoutedEventArgs()));
        }

        private void MainWindow_OnKeyDown(object sender, KeyEventArgs e)
        {
            if(_userInputActions.ContainsKey(e.Key))
            {
                _userInputActions[e.Key].Invoke();
            }
        }

        private void SetTabFocusTo(string tabName)
        {
            foreach(object item in PlayerDataTabControl.Items)
            {
                if (item is TabItem tabItem)
                {
                    if (tabItem.Name == tabName)
                    {
                        tabItem.IsSelected = true;
                        return;
                    }
                }
            }
        }

        private void MainWindow_OnClosing(object sender, CancelEventArgs e)
        {
            SaveGameService.Save(_gameSession);
        }
    }
}

 

Step 5: Test the game

You should be able to start the game, change the player somehow (move to a new location, get loot from a monster, etc.), exit the game, restart the game, and see the player in the same state they were at when you exited.

You can also edit the SOSCSRPG.json file (maybe change the player’s dexterity), start the game, and see the changed value.

 

Summary

This is a good feature to have in the game. But, if we change the properties of the Model classes (or their factories), we’ll need to remember to change the code in SaveGameService.cs. When we do this, we also need to remember to change the version number and write new code to parse the new format.

 

 

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 main page

Leave a Reply

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