I was going to do internationalization for this lesson but found out it’s very different nowadays. So, I’ll need to learn the new method before I can make that change.
Instead, we’ll create an Inventory class. We can build some better logic into it, along with adding unit testing. Previously, I had a bug when trying to group items. With better testing, we would have caught that error.
In the future, we might want to add more inventory features, like limited inventory space, stacking similar items, maximum weight, etc. With a dedicated Inventory class, we’ll have all that logic in one place.
We’ll use a new technique with this Inventory class. I will be more like a “functional” language, like F#.
One aspect of functional languages is that they don’t modify state – they don’t change an object’s property values. This is called “immutability”. Instead, they create a new instance of the object, with the modified value. This can slow performance. But, for a small program like this, we won’t notice.
So, why are we doing something that will slow down the game, even if we shouldn’t ever notice the difference? Because programming in a functional style will make our program much easier to understand and test. Our “change” functions become service functions that take in an object and return an object – without modifying anything else. There are no side effects.
This might not be clear now. But, when you see the final code, with 100% unit test coverage, you should have a better idea of how (and why) to write C# code in a functional style.
Step 1: Create new class Engine\Models\Inventory.cs
This claThis class is going to replace our Player.Inventory property, whose datatype is currently List<GameItem>.
I added regions to the class, to hopefully make it a little easier to understand what the class is doing.
Lines 9 to 17 has our backing variables, _backingInventory and _backingGroupedInventoryItems. These will hold the items in the inventory, which will be exposed by readonly properties on lines 19 through 34.
Notice that the properties are all readonly. The item lists are all returned as ReadOnly collections. This prevents us from directly adding items to, or removing items from, the inventory. We also have the Boolean HasConsumable property, which is an expression-bodied property with no setter. Again, keeping the values in this object immutable.
Line 36 through 53 have our constructor, which takes a list of items as a parameter. This is the only way to set the backing values – the inventory lists.
When we want to add an item to the inventory, we create a new Inventory object, passing in the list of the current Inventory’s items, plus the new item(s) to add. If we want to remove an item from an Inventory, we get a list of the current Inventory’s items, remove the item(s) we want to get rid of, and create a new Inventory object with the shorter list of items.
After creating the new Inventory object, with the longer or shorter list of items, we’ll set the Player’s IAfter creating the new Inventory object, with the longer or shorter list of items, we’ll set the Player’s Inventory property (now an Inventory datatype, instead of a List<GameItem>) to the new/updated Inventory object.
When we do this, and raise a property changed notification, the UI will know there is a new Inventory, with new values, and update the data-bound labels.
The UI will also refresh all the properties of the Inventory object. So, we don’t need to raise individual property changed notifications for GroupedInventory, Weapons, Consumables, and HasConsumable. This will be helpful if we add more properties in the future, like Armor, Jewelry, etc.
On lines 55 through 62, we have the public function HasAllTheseItems. This acts like a property, returning a Boolean result. But it takes in a list of items to check for. It’s still a read-only function that doesn’t modify the backing values – keeping our object immutable.
Lines 64 through 84 just has a private function to populate the backing variable for the GroupedInventory, which is called from the constructor when we instantiate a new Inventory object with GameItems.
Inventory.cs
using System.Collections.Generic;
using System.Linq;
using Engine.Services;
namespace Engine.Models
{
public class Inventory
{
#region Backing variables
private readonly List<GameItem> _backingInventory =
new List<GameItem>();
private readonly List<GroupedInventoryItem> _backingGroupedInventoryItems =
new List<GroupedInventoryItem>();
#endregion
#region Properties
public IReadOnlyList<GameItem> Items => _backingInventory.AsReadOnly();
public IReadOnlyList<GroupedInventoryItem> GroupedInventory =>
_backingGroupedInventoryItems.AsReadOnly();
public IReadOnlyList<GameItem> Weapons =>
_backingInventory.ItemsThatAre(GameItem.ItemCategory.Weapon).AsReadOnly();
public IReadOnlyList<GameItem> Consumables =>
_backingInventory.ItemsThatAre(GameItem.ItemCategory.Consumable).AsReadOnly();
public bool HasConsumable => Consumables.Any();
#endregion
#region Constructors
public Inventory(IEnumerable<GameItem> items = null)
{
if(items == null)
{
return;
}
foreach(GameItem item in items)
{
_backingInventory.Add(item);
AddItemToGroupedInventory(item);
}
}
#endregion
#region Public functions
public bool HasAllTheseItems(IEnumerable<ItemQuantity> items)
{
return items.All(item => Items.Count(i => i.ItemTypeID == item.ItemID) >= item.Quantity);
}
#endregion
#region Private functions
// REFACTOR: Look for a better way to do this (extension method?)
private void AddItemToGroupedInventory(GameItem item)
{
if(item.IsUnique)
{
_backingGroupedInventoryItems.Add(new GroupedInventoryItem(item, 1));
}
else
{
if(_backingGroupedInventoryItems.All(gi => gi.Item.ItemTypeID != item.ItemTypeID))
{
_backingGroupedInventoryItems.Add(new GroupedInventoryItem(item, 0));
}
_backingGroupedInventoryItems.First(gi => gi.Item.ItemTypeID == item.ItemTypeID).Quantity++;
}
}
#endregion
}
}
Step 2: Create new Engine\Services\InventoryService.cs
This is where we hold the functions to create new Inventory objects, with more or fewer items. I wrote the functions as extension functions, so we can call them from an Inventory object. These functions return new Inventory objects, which we will put into the Player’s Inventory property.
All the AddItem functions ultimately call the version on line 24, which instantiates a new Inventory object with the current Inventory object’s items concatenated with the list of new items.
The RemoveItems functions create a working list of the current items, remove any items passed into the RemoveItems function, and creates a new Inventory object with the shortened list of GameItems.
I also added the ItemsThatAre() function on line 85. It’s not like the other functions in this class. It’s used to get a list of GameItems in the Inventory that are a certain type (Weapon, Consumable, etc.). This could go into a general-purpose extension method class. But, because it’s only one short function, I didn’t want to create a new class for it.
If we start to have more extension methods for the Inventory class, we’ll create a new class then.
InventoryService.cs
using System.Collections.Generic;
using System.Linq;
using Engine.Factories;
using Engine.Models;
namespace Engine.Services
{
// This service lets us write C# code that is more "functional".
// Instead of modifying existing objects, and their properties,
// we always instantiate new objects that have the modified values.
public static class InventoryService
{
public static Inventory AddItem(this Inventory inventory, GameItem item)
{
return inventory.AddItems(new List<GameItem> {item});
}
public static Inventory AddItemFromFactory(this Inventory inventory, int itemTypeID)
{
return inventory.AddItems(new List<GameItem> {ItemFactory.CreateGameItem(itemTypeID)});
}
public static Inventory AddItems(this Inventory inventory, IEnumerable<GameItem> items)
{
return new Inventory(inventory.Items.Concat(items));
}
public static Inventory AddItems(this Inventory inventory,
IEnumerable<ItemQuantity> itemQuantities)
{
List<GameItem> itemsToAdd = new List<GameItem>();
foreach(ItemQuantity itemQuantity in itemQuantities)
{
for(int i = 0; i < itemQuantity.Quantity; i++)
{
itemsToAdd.Add(ItemFactory.CreateGameItem(itemQuantity.ItemID));
}
}
return inventory.AddItems(itemsToAdd);
}
public static Inventory RemoveItem(this Inventory inventory, GameItem item)
{
return inventory.RemoveItems(new List<GameItem> {item});
}
public static Inventory RemoveItems(this Inventory inventory, IEnumerable<GameItem> items)
{
// REFACTOR: Look for a cleaner solution, with fewer temporary variables.
List<GameItem> workingInventory = inventory.Items.ToList();
IEnumerable<GameItem> itemsToRemove = items.ToList();
foreach(GameItem item in itemsToRemove)
{
workingInventory.Remove(item);
}
return new Inventory(workingInventory);
}
public static Inventory RemoveItems(this Inventory inventory,
IEnumerable<ItemQuantity> itemQuantities)
{
// REFACTOR
Inventory workingInventory = inventory;
foreach(ItemQuantity itemQuantity in itemQuantities)
{
for(int i = 0; i < itemQuantity.Quantity; i++)
{
workingInventory =
workingInventory
.RemoveItem(workingInventory
.Items
.First(item => item.ItemTypeID == itemQuantity.ItemID));
}
}
return workingInventory;
}
public static List<GameItem> ItemsThatAre(this IEnumerable<GameItem> inventory,
GameItem.ItemCategory category)
{
return inventory.Where(i => i.Category == category).ToList();
}
}
}
Step 3: Modify Engine\Models\LivingEntity.cs
Now that we have a nice, clean Inventory class, we’ll get rid of the inventory functions from the LivingEntity class.
We add and delete a lot of code for this change, and the line numbers will change. So, I’m going to try to describe the general areas and functions where to make the change.
First, in the “using” sections at the top, add “using Engine.Services;” You can also remove the using directives for System.Collections.ObjectModel and System.Linq.
Next, delete the following properties: Inventory, GroupedInventory, Weapons, Consumables, and HasConsumable.
Then, add the new backing variable “_inventory” (line 18, in the code below) and the Inventory property that exposes it (lines 70-78 below). Notice that the setter for the Inventory property is private. This is another way to prevent modifying the Player’s inventory without going through the new functional methods.
In the constructor, remove the two lines that set the values of the Inventory and GroupedInventory properties and add the new line that populates the new Inventory property (line 138 below).
Finally, replace all the inventory-changing code in AddItemToInventory(), RemoveItemFromInventory(), and RemoveItemsFromInventory() with calls to the new InventoryService extension methods. These methods return the new, modified Inventory object that we will populate into the Player’s Inventory property (which forces the UI to refresh, from the property changed notification).
LivingEntity.cs
using System;
using System.Collections.Generic;
using Engine.Services;
namespace Engine.Models
{
public abstract class LivingEntity : BaseNotificationClass
{
#region Properties
private string _name;
private int _currentHitPoints;
private int _maximumHitPoints;
private int _gold;
private int _level;
private GameItem _currentWeapon;
private GameItem _currentConsumable;
private Inventory _inventory;
public string Name
{
get { return _name; }
private set
{
_name = value;
OnPropertyChanged();
}
}
public int CurrentHitPoints
{
get { return _currentHitPoints; }
private set
{
_currentHitPoints = value;
OnPropertyChanged();
}
}
public int MaximumHitPoints
{
get { return _maximumHitPoints; }
protected set
{
_maximumHitPoints = value;
OnPropertyChanged();
}
}
public int Gold
{
get { return _gold; }
private set
{
_gold = value;
OnPropertyChanged();
}
}
public int Level
{
get { return _level; }
protected set
{
_level = value;
OnPropertyChanged();
}
}
public Inventory Inventory
{
get => _inventory;
private set
{
_inventory = value;
OnPropertyChanged();
}
}
public GameItem CurrentWeapon
{
get { return _currentWeapon; }
set
{
if(_currentWeapon != null)
{
_currentWeapon.Action.OnActionPerformed -= RaiseActionPerformedEvent;
}
_currentWeapon = value;
if (_currentWeapon != null)
{
_currentWeapon.Action.OnActionPerformed += RaiseActionPerformedEvent;
}
OnPropertyChanged();
}
}
public GameItem CurrentConsumable
{
get => _currentConsumable;
set
{
if(_currentConsumable != null)
{
_currentConsumable.Action.OnActionPerformed -= RaiseActionPerformedEvent;
}
_currentConsumable = value;
if (_currentConsumable != null)
{
_currentConsumable.Action.OnActionPerformed += RaiseActionPerformedEvent;
}
OnPropertyChanged();
}
}
public bool IsDead => CurrentHitPoints <= 0;
#endregion
public event EventHandler<string> OnActionPerformed;
public event EventHandler OnKilled;
protected LivingEntity(string name, int maximumHitPoints, int currentHitPoints,
int gold, int level = 1)
{
Name = name;
MaximumHitPoints = maximumHitPoints;
CurrentHitPoints = currentHitPoints;
Gold = gold;
Level = level;
Inventory = new Inventory();
}
public void UseCurrentWeaponOn(LivingEntity target)
{
CurrentWeapon.PerformAction(this, target);
}
public void UseCurrentConsumable()
{
CurrentConsumable.PerformAction(this, this);
RemoveItemFromInventory(CurrentConsumable);
}
public void TakeDamage(int hitPointsOfDamage)
{
CurrentHitPoints -= hitPointsOfDamage;
if(IsDead)
{
CurrentHitPoints = 0;
RaiseOnKilledEvent();
}
}
public void Heal(int hitPointsToHeal)
{
CurrentHitPoints += hitPointsToHeal;
if(CurrentHitPoints > MaximumHitPoints)
{
CurrentHitPoints = MaximumHitPoints;
}
}
public void CompletelyHeal()
{
CurrentHitPoints = MaximumHitPoints;
}
public void ReceiveGold(int amountOfGold)
{
Gold += amountOfGold;
}
public void SpendGold(int amountOfGold)
{
if(amountOfGold > Gold)
{
throw new ArgumentOutOfRangeException($"{Name} only has {Gold} gold, and cannot spend {amountOfGold} gold");
}
Gold -= amountOfGold;
}
public void AddItemToInventory(GameItem item)
{
Inventory = Inventory.AddItem(item);
}
public void RemoveItemFromInventory(GameItem item)
{
Inventory = Inventory.RemoveItem(item);
}
public void RemoveItemsFromInventory(IEnumerable<ItemQuantity> itemQuantities)
{
Inventory = Inventory.RemoveItems(itemQuantities);
}
#region Private functions
private void RaiseOnKilledEvent()
{
OnKilled?.Invoke(this, new System.EventArgs());
}
private void RaiseActionPerformedEvent(object sender, string result)
{
OnActionPerformed?.Invoke(this, result);
}
#endregion
}
Step 4: Modify Engine\ViewModels\GameSession.cs
We need to make a few changes to the GameSession class.
On line 127 (where we check if the player does not have a weapon), change:
“!CurrentPlayer.Weapons.Any()” to “!CurrentPlayer.Inventory.Weapons.Any()”
On line 185 (where we see if the player has the items to complete a quest), change:
“CurrentPlayer.HasAllTheseItems” to “CurrentPlayer.Inventory.HasAllTheseItems”
On line 284 (where we see if the player has all the ingredients to craft an item), change:
“CurrentPlayer.HasAllTheseItems” to “CurrentPlayer.Inventory.HasAllTheseItems”
On line 338 (where we get the defeated monster’s loot items), change:
“CurrentMonster.Inventory” to “CurrentMonster.Inventory.Items”
GameSession.cs
using System;
using System.Linq;
using Engine.EventArgs;
using Engine.Factories;
using Engine.Models;
namespace Engine.ViewModels
{
public class GameSession : BaseNotificationClass
{
public event EventHandler<GameMessageEventArgs> OnMessageRaised;
#region Properties
private Player _currentPlayer;
private Location _currentLocation;
private Monster _currentMonster;
private Trader _currentTrader;
public World CurrentWorld { get; }
public Player CurrentPlayer
{
get { return _currentPlayer; }
set
{
if(_currentPlayer != null)
{
_currentPlayer.OnActionPerformed -= OnCurrentPlayerPerformedAction;
_currentPlayer.OnLeveledUp -= OnCurrentPlayerLeveledUp;
_currentPlayer.OnKilled -= OnCurrentPlayerKilled;
}
_currentPlayer = value;
if (_currentPlayer != null)
{
_currentPlayer.OnActionPerformed += OnCurrentPlayerPerformedAction;
_currentPlayer.OnLeveledUp += OnCurrentPlayerLeveledUp;
_currentPlayer.OnKilled += OnCurrentPlayerKilled;
}
}
}
public Location CurrentLocation
{
get { return _currentLocation; }
set
{
_currentLocation = value;
OnPropertyChanged();
OnPropertyChanged(nameof(HasLocationToNorth));
OnPropertyChanged(nameof(HasLocationToEast));
OnPropertyChanged(nameof(HasLocationToWest));
OnPropertyChanged(nameof(HasLocationToSouth));
CompleteQuestsAtLocation();
GivePlayerQuestsAtLocation();
GetMonsterAtLocation();
CurrentTrader = CurrentLocation.TraderHere;
}
}
public Monster CurrentMonster
{
get { return _currentMonster; }
set
{
if(_currentMonster != null)
{
_currentMonster.OnActionPerformed -= OnCurrentMonsterPerformedAction;
_currentMonster.OnKilled -= OnCurrentMonsterKilled;
}
_currentMonster = value;
if(_currentMonster != null)
{
_currentMonster.OnActionPerformed += OnCurrentMonsterPerformedAction;
_currentMonster.OnKilled += OnCurrentMonsterKilled;
RaiseMessage("");
RaiseMessage($"You see a {CurrentMonster.Name} here!");
}
OnPropertyChanged();
OnPropertyChanged(nameof(HasMonster));
}
}
public Trader CurrentTrader
{
get { return _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()
{
CurrentPlayer = new Player("Scott", "Fighter", 0, 10, 10, 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));
CurrentWorld = WorldFactory.CreateWorld();
CurrentLocation = CurrentWorld.LocationAt(0, 0);
}
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);
RaiseMessage("");
RaiseMessage($"You completed the '{quest.Name}' quest");
// Give the player the quest rewards
RaiseMessage($"You receive {quest.RewardExperiencePoints} experience points");
CurrentPlayer.AddExperience(quest.RewardExperiencePoints);
RaiseMessage($"You receive {quest.RewardGold} gold");
CurrentPlayer.ReceiveGold(quest.RewardGold);
foreach(ItemQuantity itemQuantity in quest.RewardItems)
{
GameItem rewardItem = ItemFactory.CreateGameItem(itemQuantity.ItemID);
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));
RaiseMessage("");
RaiseMessage($"You receive the '{quest.Name}' quest");
RaiseMessage(quest.Description);
RaiseMessage("Return with:");
foreach(ItemQuantity itemQuantity in quest.ItemsToComplete)
{
RaiseMessage($" {itemQuantity.Quantity} {ItemFactory.CreateGameItem(itemQuantity.ItemID).Name}");
}
RaiseMessage("And you will receive:");
RaiseMessage($" {quest.RewardExperiencePoints} experience points");
RaiseMessage($" {quest.RewardGold} gold");
foreach(ItemQuantity itemQuantity in quest.RewardItems)
{
RaiseMessage($" {itemQuantity.Quantity} {ItemFactory.CreateGameItem(itemQuantity.ItemID).Name}");
}
}
}
}
private void GetMonsterAtLocation()
{
CurrentMonster = CurrentLocation.GetMonster();
}
public void AttackCurrentMonster()
{
if(CurrentMonster == null)
{
return;
}
if(CurrentPlayer.CurrentWeapon == null)
{
RaiseMessage("You must select a weapon, to attack.");
return;
}
CurrentPlayer.UseCurrentWeaponOn(CurrentMonster);
if(CurrentMonster.IsDead)
{
// Get another monster to fight
GetMonsterAtLocation();
}
else
{
CurrentMonster.UseCurrentWeaponOn(CurrentPlayer);
}
}
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);
RaiseMessage($"You craft 1 {outputItem.Name}");
}
}
}
else
{
RaiseMessage("You do not have the required ingredients:");
foreach(ItemQuantity itemQuantity in recipe.Ingredients)
{
RaiseMessage($" {itemQuantity.Quantity} {ItemFactory.ItemName(itemQuantity.ItemID)}");
}
}
}
private void OnCurrentPlayerPerformedAction(object sender, string result)
{
RaiseMessage(result);
}
private void OnCurrentMonsterPerformedAction(object sender, string result)
{
RaiseMessage(result);
}
private void OnCurrentPlayerKilled(object sender, System.EventArgs eventArgs)
{
RaiseMessage("");
RaiseMessage("You have been killed.");
CurrentLocation = CurrentWorld.LocationAt(0, -1);
CurrentPlayer.CompletelyHeal();
}
private void OnCurrentMonsterKilled(object sender, System.EventArgs eventArgs)
{
RaiseMessage("");
RaiseMessage($"You defeated the {CurrentMonster.Name}!");
RaiseMessage($"You receive {CurrentMonster.RewardExperiencePoints} experience points.");
CurrentPlayer.AddExperience(CurrentMonster.RewardExperiencePoints);
RaiseMessage($"You receive {CurrentMonster.Gold} gold.");
CurrentPlayer.ReceiveGold(CurrentMonster.Gold);
foreach(GameItem gameItem in CurrentMonster.Inventory.Items)
{
RaiseMessage($"You receive one {gameItem.Name}.");
CurrentPlayer.AddItemToInventory(gameItem);
}
}
private void OnCurrentPlayerLeveledUp(object sender, System.EventArgs eventArgs)
{
RaiseMessage($"You are now level {CurrentPlayer.Level}!");
}
private void RaiseMessage(string message)
{
OnMessageRaised?.Invoke(this, new GameMessageEventArgs(message));
}
}
}
Step 5: Create TestEngine\Models\TestInventory.cs
I created this class while writing the code, since unit tests help me make sure I don’t make any breaking changes. But you can add your copy now.
These unit tests try out the different ways to add and remove items in an Inventory object. They also test a possible error condition, if we try to remove more items than the player has in their inventory.
The nice thing about these tests is that they test 100% of the code. So, if we make changes in the future, we can run the tests and be reasonably-confident our change didn’t break any existing behavior.
If we find any problems with the Inventory or InventoryService code, we can add another test here.
You can see in the tests how the “functional” inventory is easy to test. We don’t need to create a Player object, or any other infrastructure – just so we can test one two classes. This is one of the cool things about functional programming.
TestInventory.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Engine.Factories;
using Engine.Models;
using Engine.Services;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace TestEngine.Models
{
[TestClass]
public class TestInventory
{
[TestMethod]
public void Test_Instantiate()
{
Inventory inventory = new Inventory();
Assert.AreEqual(0, inventory.Items.Count);
}
[TestMethod]
public void Test_AddItem()
{
Inventory inventory = new Inventory();
Inventory inventory1 = inventory.AddItemFromFactory(3001);
Assert.AreEqual(1, inventory1.Items.Count);
}
[TestMethod]
public void Test_AddItems()
{
Inventory inventory = new Inventory();
List<GameItem> itemsToAdd = new List<GameItem>();
itemsToAdd.Add(ItemFactory.CreateGameItem(3001));
itemsToAdd.Add(ItemFactory.CreateGameItem(3002));
Inventory inventory1 =
inventory.AddItems(itemsToAdd);
Assert.AreEqual(2, inventory1.Items.Count);
// Notice the used of chained AddItemFromFactory() calls
Inventory inventory2 =
inventory1
.AddItemFromFactory(3001)
.AddItemFromFactory(3002);
Assert.AreEqual(4, inventory2.Items.Count);
}
[TestMethod]
public void Test_AddItemQuantities()
{
Inventory inventory = new Inventory();
Inventory inventory1 =
inventory.AddItems(new List<ItemQuantity> {new ItemQuantity(1001, 3)});
Assert.AreEqual(3, inventory1.Items.Count(i => i.ItemTypeID == 1001));
Inventory inventory2 =
inventory1.AddItemFromFactory(1001);
Assert.AreEqual(4, inventory2.Items.Count(i => i.ItemTypeID == 1001));
Inventory inventory3 =
inventory2.AddItems(new List<ItemQuantity> {new ItemQuantity(1002, 1)});
Assert.AreEqual(4, inventory3.Items.Count(i => i.ItemTypeID == 1001));
Assert.AreEqual(1, inventory3.Items.Count(i => i.ItemTypeID == 1002));
}
[TestMethod]
public void Test_RemoveItem()
{
Inventory inventory = new Inventory();
GameItem item1 = ItemFactory.CreateGameItem(3001);
GameItem item2 = ItemFactory.CreateGameItem(3002);
Inventory inventory1 =
inventory.AddItems(new List<GameItem> {item1, item2});
Inventory inventory2 =
inventory1.RemoveItem(item1);
Assert.AreEqual(1, inventory2.Items.Count);
}
[TestMethod]
public void Test_RemoveItems()
{
Inventory inventory = new Inventory();
GameItem item1 = ItemFactory.CreateGameItem(3001);
GameItem item2 = ItemFactory.CreateGameItem(3002);
GameItem item3 = ItemFactory.CreateGameItem(3002);
Inventory inventory1 =
inventory.AddItems(new List<GameItem> {item1, item2, item3});
Inventory inventory2 =
inventory1.RemoveItems(new List<GameItem> {item2, item3});
Assert.AreEqual(1, inventory2.Items.Count);
}
[TestMethod]
public void Test_CategorizedItemProperties()
{
// Initial empty inventory
Inventory inventory = new Inventory();
Assert.AreEqual(0, inventory.Weapons.Count);
Assert.AreEqual(0, inventory.Consumables.Count);
// Add a pointy stick (weapon)
Inventory inventory1 = inventory.AddItemFromFactory(1001);
Assert.AreEqual(1, inventory1.Weapons.Count);
Assert.AreEqual(0, inventory1.Consumables.Count);
// Add oats (NOT a consumable)
Inventory inventory2 = inventory1.AddItemFromFactory(3001);
Assert.AreEqual(1, inventory2.Weapons.Count);
Assert.AreEqual(0, inventory2.Consumables.Count);
// Add a rusty sword (weapon)
Inventory inventory3 = inventory2.AddItemFromFactory(1002);
Assert.AreEqual(2, inventory3.Weapons.Count);
Assert.AreEqual(0, inventory3.Consumables.Count);
// Add a granola bar (IS a consumable)
Inventory inventory4 = inventory3.AddItemFromFactory(2001);
Assert.AreEqual(2, inventory4.Weapons.Count);
Assert.AreEqual(1, inventory4.Consumables.Count);
}
[TestMethod]
public void Test_RemoveItemQuantities()
{
// Initial empty inventory
Inventory inventory = new Inventory();
Assert.AreEqual(0, inventory.Weapons.Count);
Assert.AreEqual(0, inventory.Consumables.Count);
Inventory inventory2 =
inventory
.AddItemFromFactory(1001)
.AddItemFromFactory(1002)
.AddItemFromFactory(1002)
.AddItemFromFactory(1002)
.AddItemFromFactory(1002)
.AddItemFromFactory(3001)
.AddItemFromFactory(3001);
Assert.AreEqual(1, inventory2.Items.Count(i => i.ItemTypeID == 1001));
Assert.AreEqual(4, inventory2.Items.Count(i => i.ItemTypeID == 1002));
Assert.AreEqual(2, inventory2.Items.Count(i => i.ItemTypeID == 3001));
Inventory inventory3 =
inventory2
.RemoveItems(new List<ItemQuantity> {new ItemQuantity(1002, 2)});
Assert.AreEqual(1, inventory3.Items.Count(i => i.ItemTypeID == 1001));
Assert.AreEqual(2, inventory3.Items.Count(i => i.ItemTypeID == 1002));
Assert.AreEqual(2, inventory3.Items.Count(i => i.ItemTypeID == 3001));
Inventory inventory4 =
inventory3
.RemoveItems(new List<ItemQuantity> {new ItemQuantity(1002, 1)});
Assert.AreEqual(1, inventory4.Items.Count(i => i.ItemTypeID == 1001));
Assert.AreEqual(1, inventory4.Items.Count(i => i.ItemTypeID == 1002));
Assert.AreEqual(2, inventory4.Items.Count(i => i.ItemTypeID == 3001));
}
[TestMethod]
[ExpectedException(typeof(InvalidOperationException))]
public void Test_RemoveItemQuantities_RemoveTooMany()
{
// Initial empty inventory
Inventory inventory = new Inventory();
Assert.AreEqual(0, inventory.Weapons.Count);
Assert.AreEqual(0, inventory.Consumables.Count);
Inventory inventory2 =
inventory
.AddItemFromFactory(1001)
.AddItemFromFactory(1002)
.AddItemFromFactory(1002)
.AddItemFromFactory(1002)
.AddItemFromFactory(1002)
.AddItemFromFactory(3001)
.AddItemFromFactory(3001);
Assert.AreEqual(1, inventory2.Items.Count(i => i.ItemTypeID == 1001));
Assert.AreEqual(4, inventory2.Items.Count(i => i.ItemTypeID == 1002));
Assert.AreEqual(2, inventory2.Items.Count(i => i.ItemTypeID == 3001));
// Should throw an exception,
// since we are trying to remove more items than exist in the inventory.
Inventory inventory3 =
inventory2
.RemoveItems(new List<ItemQuantity> {new ItemQuantity(1002, 999)});
}
}
}
Step 6: Modify WPFUI\MainWindow.xaml
Now that we’ve moved some of the inventory-related properties into the Inventory class, we need to update the bindings in the XAML.
Around line 172 (the Player’s Inventory tab), change:
“CurrentPlayer.GroupedInventory” to “CurrentPlayer.Inventory.GroupedInventory“
On line 273 (the weapon combobox), change:
“CurrentPlayer.Weapons” to “CurrentPlayer.Inventory.Weapons”
On lines 284 and 285 (the consumable combobox), change:
“CurrentPlayer.HasConsumable” to “CurrentPlayer.Inventory.HasConsumable”
“CurrentPlayer.Consumables” to “CurrentPlayer.Inventory.Consumables”
On line 290 (the consumable’s “Use” button”), change:
“CurrentPlayer.HasConsumable” to “CurrentPlayer.Inventory.HasConsumable”
MainWindow.xaml
<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">
<Window.Resources>
<BooleanToVisibilityConverter x:Key="BooleanToVisibility" />
</Window.Resources>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="225"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="250"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- Menu -->
<Label Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="2" Content="Menu" Background="AliceBlue"/>
<!-- Player stats -->
<Grid Grid.Row="1" Grid.Column="0" Background="Aquamarine">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Label Grid.Row="0" Grid.Column="0" Content="Name:"/>
<Label Grid.Row="0" Grid.Column="1" Content="{Binding CurrentPlayer.Name}"/>
<Label Grid.Row="1" Grid.Column="0" Content="Class:"/>
<Label Grid.Row="1" Grid.Column="1" Content="{Binding CurrentPlayer.CharacterClass}"/>
<Label Grid.Row="2" Grid.Column="0" Content="Hit points:"/>
<Label Grid.Row="2" Grid.Column="1" Content="{Binding CurrentPlayer.CurrentHitPoints}"/>
<Label Grid.Row="3" Grid.Column="0" Content="Gold:"/>
<Label Grid.Row="3" Grid.Column="1" Content="{Binding CurrentPlayer.Gold}"/>
<Label Grid.Row="4" Grid.Column="0" Content="XP:"/>
<Label Grid.Row="4" Grid.Column="1" Content="{Binding CurrentPlayer.ExperiencePoints}"/>
<Label Grid.Row="5" Grid.Column="0" Content="Level:"/>
<Label Grid.Row="5" Grid.Column="1" Content="{Binding CurrentPlayer.Level}"/>
</Grid>
<!-- Gameplay -->
<Grid Grid.Row="1" Grid.Column="1"
Background="Beige">
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="3*"/>
<ColumnDefinition Width="2*"/>
</Grid.ColumnDefinitions>
<!-- Game Messages -->
<Border Grid.Row="0" Grid.Column="0"
Grid.RowSpan="2"
BorderBrush="Gainsboro"
BorderThickness="1">
<RichTextBox x:Name="GameMessages"
Background="Beige"
VerticalScrollBarVisibility="Auto">
<RichTextBox.Resources>
<Style TargetType="{x:Type Paragraph}">
<Setter Property="Margin" Value="0"/>
</Style>
</RichTextBox.Resources>
</RichTextBox>
</Border>
<!-- Location information -->
<Border Grid.Row="0" Grid.Column="1"
BorderBrush="Gainsboro"
BorderThickness="1">
<Grid Margin="3">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0"
HorizontalAlignment="Center"
Text="{Binding CurrentLocation.Name}"/>
<Image Grid.Row="1"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Height="125"
Width="125"
Source="{Binding CurrentLocation.ImageName,
Converter={StaticResource FileToBitmapConverter}}"/>
<TextBlock Grid.Row="2"
HorizontalAlignment="Center"
Text="{Binding CurrentLocation.Description}"
TextWrapping="Wrap"/>
</Grid>
</Border>
<!-- Monster information -->
<Border Grid.Row="1" Grid.Column="1"
BorderBrush="Gainsboro"
BorderThickness="1">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0"
HorizontalAlignment="Center"
Height="Auto"
Text="{Binding CurrentMonster.Name}" />
<Image Grid.Row="1"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Height="125"
Width="125"
Source="{Binding CurrentMonster.ImageName,
Converter={StaticResource FileToBitmapConverter}}"/>
<StackPanel Grid.Row="2"
Visibility="{Binding HasMonster, Converter={StaticResource BooleanToVisibility}}"
HorizontalAlignment="Center"
Orientation="Horizontal">
<TextBlock>Current Hit Points:</TextBlock>
<TextBlock Text="{Binding CurrentMonster.CurrentHitPoints}" />
</StackPanel>
</Grid>
</Border>
</Grid>
<!-- Inventory, Quests, and Recipes -->
<Grid Grid.Row="2" Grid.Column="0"
Background="BurlyWood">
<TabControl x:Name="PlayerDataTabControl">
<TabItem Header="Inventory"
x:Name="InventoryTabItem">
<DataGrid ItemsSource="{Binding CurrentPlayer.Inventory.GroupedInventory}"
AutoGenerateColumns="False"
HeadersVisibility="Column">
<DataGrid.Columns>
<DataGridTextColumn Header="Description"
Binding="{Binding Item.Name, Mode=OneWay}"
Width="*"/>
<DataGridTextColumn Header="Qty"
IsReadOnly="True"
Width="Auto"
Binding="{Binding Quantity, Mode=OneWay}"/>
<DataGridTextColumn Header="Price"
Binding="{Binding Item.Price, Mode=OneWay}"
Width="Auto"/>
</DataGrid.Columns>
</DataGrid>
</TabItem>
<TabItem Header="Quests"
x:Name="QuestsTabItem">
<DataGrid ItemsSource="{Binding CurrentPlayer.Quests}"
AutoGenerateColumns="False"
HeadersVisibility="Column">
<DataGrid.Columns>
<DataGridTextColumn Header="Name"
Binding="{Binding PlayerQuest.Name, Mode=OneWay}"
Width="*">
<DataGridTextColumn.CellStyle>
<Style TargetType="DataGridCell">
<Setter Property="ToolTip"
Value="{Binding PlayerQuest.ToolTipContents}"/>
</Style>
</DataGridTextColumn.CellStyle>
</DataGridTextColumn>
<DataGridTextColumn Header="Done?"
Binding="{Binding IsCompleted, Mode=OneWay}"
Width="Auto"/>
</DataGrid.Columns>
</DataGrid>
</TabItem>
<TabItem Header="Recipes"
x:Name="RecipesTabItem">
<DataGrid ItemsSource="{Binding CurrentPlayer.Recipes}"
AutoGenerateColumns="False"
HeadersVisibility="Column">
<DataGrid.Columns>
<DataGridTextColumn Header="Name"
Binding="{Binding Name, Mode=OneWay}"
Width="*">
<DataGridTextColumn.CellStyle>
<Style TargetType="DataGridCell">
<Setter Property="ToolTip"
Value="{Binding ToolTipContents}"/>
</Style>
</DataGridTextColumn.CellStyle>
</DataGridTextColumn>
<DataGridTemplateColumn MinWidth="75">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Button Click="OnClick_Craft"
Width="55"
Content="Craft"/>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
</DataGrid.Columns>
</DataGrid>
</TabItem>
</TabControl>
</Grid>
<!-- Action controls -->
<Grid Grid.Row="2" Grid.Column="1"
Background="Lavender">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="255" />
</Grid.ColumnDefinitions>
<!-- Combat Controls -->
<Grid Grid.Row="0" Grid.Column="0"
HorizontalAlignment="Center"
VerticalAlignment="Center">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="150"/>
<ColumnDefinition Width="10"/>
<ColumnDefinition Width="50"/>
</Grid.ColumnDefinitions>
<ComboBox Grid.Row="0" Grid.Column="0"
Visibility="{Binding HasMonster, Converter={StaticResource BooleanToVisibility}}"
ItemsSource="{Binding CurrentPlayer.Inventory.Weapons}"
SelectedItem="{Binding CurrentPlayer.CurrentWeapon}"
DisplayMemberPath="Name"/>
<Button Grid.Row="0" Grid.Column="2"
Visibility="{Binding HasMonster, Converter={StaticResource BooleanToVisibility}}"
Content="Use"
Click="OnClick_AttackMonster"/>
<ComboBox Grid.Row="1" Grid.Column="0"
Visibility="{Binding CurrentPlayer.Inventory.HasConsumable, Converter={StaticResource BooleanToVisibility}}"
ItemsSource="{Binding CurrentPlayer.Inventory.Consumables}"
SelectedItem="{Binding CurrentPlayer.CurrentConsumable}"
DisplayMemberPath="Name"/>
<Button Grid.Row="1" Grid.Column="2"
Visibility="{Binding CurrentPlayer.Inventory.HasConsumable, Converter={StaticResource BooleanToVisibility}}"
Content="Use"
Click="OnClick_UseCurrentConsumable"/>
</Grid>
<!-- Movement Controls -->
<Grid Grid.Row="0" Grid.Column="1">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="*" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Button Grid.Row="0" Grid.Column="1"
Height="25" Width="65" Margin="10"
Click="OnClick_MoveNorth"
Visibility="{Binding HasLocationToNorth, Converter={StaticResource BooleanToVisibility}}"
Content="North"/>
<Button Grid.Row="1" Grid.Column="0"
Height="25" Width="65" Margin="10"
Click="OnClick_MoveWest"
Visibility="{Binding HasLocationToWest, Converter={StaticResource BooleanToVisibility}}"
Content="West"/>
<Button Grid.Row="1" Grid.Column="1"
Height="25" Width="65" Margin="10"
Click="OnClick_DisplayTradeScreen"
Visibility="{Binding HasTrader, Converter={StaticResource BooleanToVisibility}}"
Content="Trade"/>
<Button Grid.Row="1" Grid.Column="2"
Height="25" Width="65" Margin="10"
Click="OnClick_MoveEast"
Visibility="{Binding HasLocationToEast, Converter={StaticResource BooleanToVisibility}}"
Content="East"/>
<Button Grid.Row="2" Grid.Column="1"
Height="25" Width="65" Margin="10"
Click="OnClick_MoveSouth"
Visibility="{Binding HasLocationToSouth, Converter={StaticResource BooleanToVisibility}}"
Content="South"/>
</Grid>
</Grid>
</Grid>
</Window>
Step 7: Modify WPFUI\TradeScreen.xaml
We also need to update the inventory bindings in the trade screen.
On line 40 (the player’s inventory datagrid ItemsSource), change:
“CurrentPlayer.GroupedInventory” to “CurrentPlayer.Inventory.GroupedInventory”
On line 75 (the trader’s inventory datagrid ItemsSource), change:
“CurrentTrader.GroupedInventory” to “CurrentTrader.Inventory.GroupedInventory”
TradeScreen.xaml
<Window x:Class="WPFUI.TradeScreen"
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"
WindowStartupLocation="CenterOwner"
FontSize="11pt"
Title="Trade Screen" Height="480" Width="640">
<Grid Margin="10">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<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"
HorizontalAlignment="Center"
Content="{Binding CurrentTrader.Name}"/>
<Label Grid.Row="1" Grid.Column="0"
HorizontalAlignment="Center"
Content="Your Inventory"/>
<Label Grid.Row="1" Grid.Column="1"
HorizontalAlignment="Center"
Content="Trader's Inventory"/>
<DataGrid Grid.Row="2" Grid.Column="0"
Margin="10"
ItemsSource="{Binding CurrentPlayer.Inventory.GroupedInventory}"
AutoGenerateColumns="False"
HeadersVisibility="Column"
CanUserAddRows="False"
CanUserDeleteRows="False"
VerticalScrollBarVisibility="Auto">
<DataGrid.Columns>
<DataGridTextColumn Header="Description"
IsReadOnly="True"
Width="*"
Binding="{Binding Item.Name}"/>
<DataGridTextColumn Header="Qty"
IsReadOnly="True"
Width="Auto"
Binding="{Binding Quantity}"/>
<DataGridTextColumn Header="Price"
IsReadOnly="True"
Width="Auto"
Binding="{Binding Item.Price}"/>
<DataGridTemplateColumn MinWidth="75">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Button Click="OnClick_Sell"
Width="55"
Content="Sell 1"/>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
</DataGrid.Columns>
</DataGrid>
<DataGrid Grid.Row="2" Grid.Column="1"
Margin="10"
ItemsSource="{Binding CurrentTrader.Inventory.GroupedInventory}"
AutoGenerateColumns="False"
HeadersVisibility="Column"
CanUserAddRows="False"
CanUserDeleteRows="False"
VerticalScrollBarVisibility="Auto">
<DataGrid.Columns>
<DataGridTextColumn Header="Description"
IsReadOnly="True"
Width="*"
Binding="{Binding Item.Name}"/>
<DataGridTextColumn Header="Qty"
IsReadOnly="True"
Width="Auto"
Binding="{Binding Quantity}"/>
<DataGridTextColumn Header="Price"
IsReadOnly="True"
Width="Auto"
Binding="{Binding Item.Price}"/>
<DataGridTemplateColumn MinWidth="75">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Button Click="OnClick_Buy"
Width="55"
Content="Buy 1"/>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
</DataGrid.Columns>
</DataGrid>
<Button Grid.Row="3" Grid.Column="1"
HorizontalAlignment="Right"
Width="75"
Content="Close"
Click="OnClick_Close"/>
</Grid>
</Window>
TEST THE GAME
We made a lot of changes, so let’s test the game. Everything should work the same as before.
NEXT LESSON: Lesson 15.4: Using GitHub to upload and download a solution
PREVIOUS LESSON: Lesson 15.2: Catch and log exceptions