Now, let’s give the user the ability to save and load game data to multiple files.
Step 1: Modify \Engine\Services\SaveGameService.cs
We’ll start by deleting the SAVE_GAME_FILE_NAME constant on line 13.
Next, add a “filename” parameter to the Save and LoadLastSaveOrCreateNew functions. Inside these functions, change the lines that used SAVE_GAME_FILE_NAME to use filename (now on lines 15, 21, and 29, after deleting the constant).
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
{
public static void Save(GameSession gameSession, string fileName)
{
File.WriteAllText(fileName,
JsonConvert.SerializeObject(gameSession, Formatting.Indented));
}
public static GameSession LoadLastSaveOrCreateNew(string fileName)
{
if(!File.Exists(fileName))
{
return new GameSession();
}
// Save game file exists, so create the GameSession object from it.
try
{
JObject data = JObject.Parse(File.ReadAllText(fileName));
// 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: Create \WPFUI\Windows\YesNoWindow.xaml
When the player exits the game, I want to have a pop-up window asking them if they’d like to save the game.
We could use a MessageBox, but it always centers on the middle of the screen – not the middle of the current window. So, I created this new WPF Window to let us ask Yes/No questions.
In the WPFUI project, add a new “Windows” folder, and add a new Window to it, named YesNoWindow.xaml.
Change the XAML and code-behind (the YesNoWindow.xaml.cs file) to the code below.
Notice that the constructor accepts a title and message parameter. That will let us customize it and use it in several places.
When the user clicks the Yes or No button, it will call the function to set the Public ClickedYes property and then close the popup window.
YesNoWindow.xaml
<Window x:Class="WPFUI.Windows.YesNoWindow"
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"
mc:Ignorable="d"
FontSize="11pt"
WindowStartupLocation="CenterOwner"
ResizeMode="NoResize"
Title="YesNoWindow"
MinHeight="125"
MinWidth="250"
SizeToContent="WidthAndHeight">
<Grid Margin="5,5,5,5">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Label Grid.Row="0" Grid.Column="0"
Grid.ColumnSpan="2"
x:Name="Message"/>
<Button Grid.Row="2" Grid.Column="0"
Content="No"
Width="75"
HorizontalAlignment="Left"
Click="No_OnClick"/>
<Button Grid.Row="2" Grid.Column="1"
Content="Yes"
Width="75"
HorizontalAlignment="Right"
Click="Yes_OnClick"/>
</Grid>
</Window>
YesNoWindow.xaml.cs
using System.Windows;
namespace WPFUI.Windows
{
public partial class YesNoWindow : Window
{
public bool ClickedYes { get; private set; }
public YesNoWindow(string title, string message)
{
InitializeComponent();
Title = title;
Message.Content = message;
}
private void Yes_OnClick(object sender, RoutedEventArgs e)
{
ClickedYes = true;
Close();
}
private void No_OnClick(object sender, RoutedEventArgs e)
{
ClickedYes = false;
Close();
}
}
}
Step 3: Modify \WPFUI\MainWindow.xaml
Delete the Label control on line 31 and replace it with the XAML code below.
The “Menu” control contains MenuItem child controls. The MenuItems at the first level (the ones where Header equals “File” and “Help”) will be displayed horizontally in the menu bar.
Those top-level MenuItem controls have their own child MenuItem controls. Those are the options you’ll see in the drop-down menu after you click on the parent MenuItem.
The “Separator” control draws horizontal lines between MenuItems, to let you group similar options.
If the MenuItem has a Click event handler, that’s the function that will run when the user selects that menu option – just like the Click event for a button.
I added a “Help” MenuItem, with two child MenuItems whose “IsEnabled” attribute is set to false. We don’t want to do anything with these menu options yet. They’re just included so you can see how a menu builds out horizontal options.
MainWindow.xaml (lines 31-54)
<Menu Grid.Row="0" Grid.Column="0"
Grid.ColumnSpan="2"
FontSize="11pt"
Background="AliceBlue">
<MenuItem Header="File">
<MenuItem Header="Start New Game"
Click="StartNewGame_OnClick"/>
<Separator/>
<MenuItem Header="Load Game"
Click="LoadGame_OnClick"/>
<MenuItem Header="Save Game"
Click="SaveGame_OnClick"/>
<Separator/>
<MenuItem Header="Exit"
Click="Exit_OnClick"/>
</MenuItem>
<MenuItem Header="Help">
<MenuItem Header="Help"
IsEnabled="False"/>
<Separator/>
<MenuItem Header="About"
IsEnabled="False"/>
</MenuItem>
</Menu>
Step 4: Modify \WPFUI\MainWindow.xaml.cs
Now that we have the menu options, we need to add the functions to run when they are selected.
We need to add two new using directives: Microsoft.Win32 for the file save/load controls, and WPFUI.Windows for the new YesNoWindow.
On line 19, add a new constant SAVE_GAME_FILE_EXTENSION to store the extension for saved game files.
Since we’re going to instantiate the GameSession object from a few different ways, I created the SetActiveGameSessionTo function, starting on line 126. This function unsubscribes from the OnMessageRaised event (so we don’t call it multiple times, if the user loads multiple games), sets the backing _gameSession variable and DataContext, clears out the GameMessages, and subscribes to the _messageBroker.
Now, on line 33 of the constructor, we call this new SetActiveGameSessionTo function to load a new GameSession object. We also removed the old subscription to _messageBroker.OnMessageRaised, since that’s handled in the SetActiveGameSessionTo function.
On line 140, we have a new StartNewGame_OnClick function that sets the active game session to a new GameSession object when the user selects the “Start New Game” menu option.
Lines 145-158 are the new functions to load a game from a save game file. OpenFileDialog pops up a window to let the user select a file. On line 150, we set the directory the dialog box will first display when opened. And on line 151, we set the file extensions we want it to display and allow selecting – in this case the constant we set at the top of the class.
Lines 160-163 are the function that is called when the user select “Save Game” from the menu. All it does is call a new SaveGame function.
Lines 170-181 are for a new function to run when the player exits the game (closes the window). It will create a new YesNoWindow, asking the user if they want to save the game before exiting. If the user answers yes, it will call the SaveGame function.
Lines 183-196 are the new SaveGame function. It pops up a file save dialog box, using the same starting directory and file name extensions as in the load game function. If the user gives a file name and clicks on the button to save the file, this function will call SaveGameService.Save, which writes the current game to disk.
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;
using Microsoft.Win32;
using WPFUI.Windows;
namespace WPFUI
{
public partial class MainWindow : Window
{
private const string SAVE_GAME_FILE_EXTENSION = "soscsrpg";
private readonly MessageBroker _messageBroker = MessageBroker.GetInstance();
private readonly Dictionary<Key, Action> _userInputActions =
new Dictionary<Key, Action>();
private GameSession _gameSession;
public MainWindow()
{
InitializeComponent();
InitializeUserInputActions();
SetActiveGameSessionTo(new 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 SetActiveGameSessionTo(GameSession gameSession)
{
// Unsubscribe from OnMessageRaised, or we will get double messages
_messageBroker.OnMessageRaised -= OnGameMessageRaised;
_gameSession = gameSession;
DataContext = _gameSession;
// Clear out previous game's messages
GameMessages.Document.Blocks.Clear();
_messageBroker.OnMessageRaised += OnGameMessageRaised;
}
private void StartNewGame_OnClick(object sender, RoutedEventArgs e)
{
SetActiveGameSessionTo(new GameSession());
}
private void LoadGame_OnClick(object sender, RoutedEventArgs e)
{
OpenFileDialog openFileDialog =
new OpenFileDialog
{
InitialDirectory = AppDomain.CurrentDomain.BaseDirectory,
Filter = $"Saved games (*.{SAVE_GAME_FILE_EXTENSION})|*.{SAVE_GAME_FILE_EXTENSION}"
};
if(openFileDialog.ShowDialog() == true)
{
SetActiveGameSessionTo(SaveGameService.LoadLastSaveOrCreateNew(openFileDialog.FileName));
}
}
private void SaveGame_OnClick(object sender, RoutedEventArgs e)
{
SaveGame();
}
private void Exit_OnClick(object sender, RoutedEventArgs e)
{
Close();
}
private void MainWindow_OnClosing(object sender, CancelEventArgs e)
{
YesNoWindow message =
new YesNoWindow("Save Game", "Do you want to save your game?");
message.Owner = GetWindow(this);
message.ShowDialog();
if (message.ClickedYes)
{
SaveGame();
}
}
private void SaveGame()
{
SaveFileDialog saveFileDialog =
new SaveFileDialog
{
InitialDirectory = AppDomain.CurrentDomain.BaseDirectory,
Filter = $"Saved games (*.{SAVE_GAME_FILE_EXTENSION})|*.{SAVE_GAME_FILE_EXTENSION}"
};
if (saveFileDialog.ShowDialog() == true)
{
SaveGameService.Save(_gameSession, saveFileDialog.FileName);
}
}
}
NEXT LESSON: Lesson 17.4: Automated test for saved game reader
PREVIOUS LESSON: Lesson 17.2: Reducing serialized data in save game file
Hi, is there an chance to change save game text ?
I mean in pop up window ?
Hi Peter,
Yes, you can change the text that is passed in to the YesNoWindow constructor on line 173 of MainWindow.xaml.cs.
Scott, thank you so much for this lesson. It demystified something I’ve always wondered about, which is saving and re-loading application data, using your own file extension. it’s much more straightforward than I ever imagined! The only other thing I’m left wondering is how to hide the raw JSON from the user so it can’t be directly edited. Not sure if this is something covered in a future lesson? 🙂
Thank you overall for this series. It’s honestly the best online tutorial for building desktop apps I’ve ever encountered. So much stuff is covered that I’d always wanted to learn but never seemed to find simple answers to. If I’d paid money for this on something like Udemy I’d have been very happy with my purchase.
You’re welcome, Chris! I’m glad to hear you found the series helpful. 🙂
None of the future lessons show how to prevent users from editing the JSON file. You could add an encryption/decryption step. For the “save” step, we currently serialize the object into a string, then save it to the file. We could somehow encrypt that string, then decrypt it when we read the string from the file and before we serialize the string into an object. You could use Base64 encoding/decoding to make the string more difficult to read. It’s not super-secure, but will prevent an average user from modifying the file’s data.
Here’s a blog post that shows how to do that: https://jasonwatmore.com/post/2020/09/12/c-encode-and-decode-base64-strings