One principle of Object-Oriented Programming is “encapsulation”.
With encapsulation, you don’t allow one class to directly change property values in another class. Instead, you have functions that control changing a property’s value. This way, you can prevent the property from being set to a bad value. You can also run additional code, if we need to.
We already do this in the AddItemToInventory and RemoveItemFromInventory function in LivingEntity – although, we don’t make it mandatory. In this lesson, we’re going to encapsulate changing a LivingEntity’s hit points and gold.
Step 1: Modify Engine\Models\LivingEntity.cs
First, we’ll make the setters for Name, CurrentHitPoints, MaximumHitPoints, and Gold to be private (lines 20, 30, 40, and 50). This way, their values can only change within other functions inside the LivingEntity class.
This will cause many errors, because we currently change those properties in many other classes.
Next, we’ll add a new Boolean property on line 64, “IsDead”. This is an “expression-body property” that returns the result of a computation.
For this property, the computation will check if CurrentHitPoints are less than, or equal to, zero. If so, the computation returns “true”. If CurrentHitPoints is greater than 0, the computation returns “false”.
We don’t need the IsDead property. I just think it is a little clearer than always checking “if(CurrentHitPoints <= 0)”.
On line 68, we add a new event “OnKilled”. The GameSession class is going to subscribe to (listen to) this event. When a LivingEntity (Player. Monster, or Trader) is killed, LivingEntity will raise this event and notify any subscribers.
Because we changed Name, MaximumHitPoints, CurrentHitPoints, and Gold to have private setters, we can only set their value inside LivingEntity. So, we are going to pass the values for those properties into the constructor and set them there. Those changes are on lines 70 through 75.
On lines 81 through 120 we have several new functions. Other classes will use these functions to change a LivingEntity’s Gold or CurrentHitPoints value. We also added some extra logic in some of these functions. For example:
TakeDamage() checks if the player is dead, after taking damage. If so, it sets their CurrentHitPoints to 0 (in case CurrentHitPoints became negative) and calls the new RaiseOnKilledEvent() function.
Heal() ensures CurrentHitPoints can never be greater than MaximumHitPoints.
SpendGold() throw an exception if we ever try to spend more gold than the LivingEntity has.
Finally, on line 167, we have the RaiseOnKilledEvent function. TakeDamage() calls this if the LivingEntity’s CurrentHitPoints drops to zero or less, to notify other classes subscribed to the OnKilled event.
LivingEntity.cs
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
namespace Engine.Models
{
public abstract class LivingEntity : BaseNotificationClass
{
#region Properties
private string _name;
private int _currentHitPoints;
private int _maximumHitPoints;
private int _gold;
public string Name
{
get { return _name; }
private set
{
_name = value;
OnPropertyChanged(nameof(Name));
}
}
public int CurrentHitPoints
{
get { return _currentHitPoints; }
private set
{
_currentHitPoints = value;
OnPropertyChanged(nameof(CurrentHitPoints));
}
}
public int MaximumHitPoints
{
get { return _maximumHitPoints; }
private set
{
_maximumHitPoints = value;
OnPropertyChanged(nameof(MaximumHitPoints));
}
}
public int Gold
{
get { return _gold; }
private set
{
_gold = value;
OnPropertyChanged(nameof(Gold));
}
}
public ObservableCollection<GameItem> Inventory { get; set; }
public ObservableCollection<GroupedInventoryItem> GroupedInventory { get; set; }
public List<GameItem> Weapons =>
Inventory.Where(i => i is Weapon).ToList();
public bool IsDead => CurrentHitPoints <= 0;
#endregion
public event EventHandler OnKilled;
protected LivingEntity(string name, int maximumHitPoints, int currentHitPoints, int gold)
{
Name = name;
MaximumHitPoints = maximumHitPoints;
CurrentHitPoints = currentHitPoints;
Gold = gold;
Inventory = new ObservableCollection<GameItem>();
GroupedInventory = new ObservableCollection<GroupedInventoryItem>();
}
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.Add(item);
if(item.IsUnique)
{
GroupedInventory.Add(new GroupedInventoryItem(item, 1));
}
else
{
if(!GroupedInventory.Any(gi => gi.Item.ItemTypeID == item.ItemTypeID))
{
GroupedInventory.Add(new GroupedInventoryItem(item, 0));
}
GroupedInventory.First(gi => gi.Item.ItemTypeID == item.ItemTypeID).Quantity++;
}
OnPropertyChanged(nameof(Weapons));
}
public void RemoveItemFromInventory(GameItem item)
{
Inventory.Remove(item);
GroupedInventoryItem groupedInventoryItemToRemove =
GroupedInventory.FirstOrDefault(gi => gi.Item == item);
if(groupedInventoryItemToRemove != null)
{
if(groupedInventoryItemToRemove.Quantity == 1)
{
GroupedInventory.Remove(groupedInventoryItemToRemove);
}
else
{
groupedInventoryItemToRemove.Quantity--;
}
}
OnPropertyChanged(nameof(Weapons));
}
#region Private functions
private void RaiseOnKilledEvent()
{
OnKilled?.Invoke(this, new System.EventArgs());
}
#endregion
}
}
Step 2: Update Engine\Models\Player.cs, Monster.cs, and Trader.cs
Because we added new parameters to the LivingEntity constructor, we need to update its child classes to pass in values for those parameters.
The Trader doesn’t really need hit points or gold (currently). So, we pass in large hard-coded values of 9999 for these parameters.
Player.cs
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
namespace Engine.Models
{
public class Player : LivingEntity
{
#region Properties
private string _characterClass;
private int _experiencePoints;
private int _level;
public string CharacterClass
{
get { return _characterClass; }
set
{
_characterClass = value;
OnPropertyChanged(nameof(CharacterClass));
}
}
public int ExperiencePoints
{
get { return _experiencePoints; }
set
{
_experiencePoints = value;
OnPropertyChanged(nameof(ExperiencePoints));
}
}
public int Level
{
get { return _level; }
set
{
_level = value;
OnPropertyChanged(nameof(Level));
}
}
public ObservableCollection<QuestStatus> Quests { get; set; }
#endregion
public Player(string name, string characterClass, int experiencePoints,
int maximumHitPoints, int currentHitPoints, int gold) :
base(name, maximumHitPoints, currentHitPoints, gold)
{
CharacterClass = characterClass;
ExperiencePoints = experiencePoints;
Quests = new ObservableCollection<QuestStatus>();
}
public bool HasAllTheseItems(List<ItemQuantity> items)
{
foreach (ItemQuantity item in items)
{
if (Inventory.Count(i => i.ItemTypeID == item.ItemID) < item.Quantity)
{
return false;
}
}
return true;
}
}
}
Monster.cs
namespace Engine.Models
{
public class Monster : LivingEntity
{
public string ImageName { get; set; }
public int MinimumDamage { get; set; }
public int MaximumDamage { get; set; }
public int RewardExperiencePoints { get; private set; }
public Monster(string name, string imageName,
int maximumHitPoints, int currentHitPoints,
int minimumDamage, int maxmumDamage,
int rewardExperiencePoints, int gold) :
base(name, maximumHitPoints, currentHitPoints, gold)
{
ImageName = $"/Engine;component/Images/Monsters/{imageName}";
MinimumDamage = minimumDamage;
MaximumDamage = maxmumDamage;
RewardExperiencePoints = rewardExperiencePoints;
}
}
}
Trader.cs
namespace Engine.Models
{
public class Trader : LivingEntity
{
public Trader(string name) : base(name, 9999, 9999, 9999)
{
}
}
}
Step 3: Modify Engine\ViewModels\GameSession.cs
The first thing we need to fix is the GameSession constructor. This is where we instantiate the CurrentPlayer object (line 121) – which now needs to have its property values passed in as constructor parameters.
Next, we’ll have GameSession subscribe to the OnKilled event for the CurrentPlayer and CurrentMonster.
To do this for CurrentPlayer, we need to change it to use a backing variable, because we need to add logic into its setter.
When an object subscribes to another object’s event, you also need to handle unsubscribing to it. This helps the program get rid of old objects in memory.
When we set the CurrentPlayer property, we want to unsubscribe from the previous _currentPlayer’s OnKilled event – if _currentPlayer already has a value. This is what’s happening on lines 27 through 30. The “-=” unsubscribes from an event. The “OnCurrentPlayerKilled” function is the function that will run if the Player object raises an OnKilled event (the RaiseOnKilledEvent function we added to LivingEntity.cs).
After unsubscribing, we set the backing variable to the new value passed to the setter on line 32.
Now that we’ve changed the backing variable to the new object for CurrentPlayer, we want to subscribe to its OnKilled event, if the value wasn’t null. This is on lines 34 through 37.
We don’t really need this for CurrentPlayer, because we only set the value once. But, it could be useful in the future.
We do need to use this technique for the CurrentMonster property – because we frequently set that property to a new object. The code for this is on lines 67 through 80.
When the CurrentMonster raises an OnKilled event, GameSession will run the OnCurrentMonsterKilled function.
Next, we need to change the AttackCurrentMonster function on line 245. Instead of having this function check if the monster or player is killed, we will let the event-handling functions deal with that.
So now, this function only needs to handle applying damage to the player or monster, and instantiating a new monster (if the current monster was killed).
I did have to change the order of some of the RaiseMessage lines. Before, they were after applying damage to the player and monster. However, with the new event-handling functions, this could lead to a problem.
For example, if lines 282 and 283 were reversed (their previous order), the player could take damage and be killed. The event-handling code moves the player to their home, if they are killed. Because they are at a new location, we try to get the monster at that location. But there is no monster at the player’s home. So, CurrentMonster gets set to null.
This would cause an error with the RaiseMessage that is now on line 282. There is no “CurrentMonster”, so we can’t display CurrentMonster.Name.
So now, we have the RaiseMessage lines before we apply any changes to an object.
On lines 288 through 295, we have the new OnCurrentPlayerKilled function. This runs when the CurrentPlayer.OnKilled event is raised. It displays a message, moves the player to their home, and completely heals them.
On lines 297 through 313, we have the new OnCurrentMonsterKilled function – for when CurrentMonster.OnKilled is raised. This now holds the code to give the player their loot from defeating the monster.
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; set; }
public Player CurrentPlayer
{
get { return _currentPlayer; }
set
{
if(_currentPlayer != null)
{
_currentPlayer.OnKilled -= OnCurrentPlayerKilled;
}
_currentPlayer = value;
if (_currentPlayer != null)
{
_currentPlayer.OnKilled += OnCurrentPlayerKilled;
}
}
}
public Location CurrentLocation
{
get { return _currentLocation; }
set
{
_currentLocation = value;
OnPropertyChanged(nameof(CurrentLocation));
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.OnKilled -= OnCurrentMonsterKilled;
}
_currentMonster = value;
if(_currentMonster != null)
{
_currentMonster.OnKilled += OnCurrentMonsterKilled;
RaiseMessage("");
RaiseMessage($"You see a {CurrentMonster.Name} here!");
}
OnPropertyChanged(nameof(CurrentMonster));
OnPropertyChanged(nameof(HasMonster));
}
}
public Trader CurrentTrader
{
get { return _currentTrader; }
set
{
_currentTrader = value;
OnPropertyChanged(nameof(CurrentTrader));
OnPropertyChanged(nameof(HasTrader));
}
}
public Weapon CurrentWeapon { get; set; }
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.Weapons.Any())
{
CurrentPlayer.AddItemToInventory(ItemFactory.CreateGameItem(1001));
}
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.HasAllTheseItems(quest.ItemsToComplete))
{
// Remove the quest completion items from the player's inventory
foreach (ItemQuantity itemQuantity in quest.ItemsToComplete)
{
for(int i = 0; i < itemQuantity.Quantity; i++)
{
CurrentPlayer.RemoveItemFromInventory(CurrentPlayer.Inventory.First(item => item.ItemTypeID == itemQuantity.ItemID));
}
}
RaiseMessage("");
RaiseMessage($"You completed the '{quest.Name}' quest");
// Give the player the quest rewards
CurrentPlayer.ExperiencePoints += quest.RewardExperiencePoints;
RaiseMessage($"You receive {quest.RewardExperiencePoints} experience points");
CurrentPlayer.ReceiveGold(quest.RewardGold);
RaiseMessage($"You receive {quest.RewardGold} gold");
foreach(ItemQuantity itemQuantity in quest.RewardItems)
{
GameItem rewardItem = ItemFactory.CreateGameItem(itemQuantity.ItemID);
CurrentPlayer.AddItemToInventory(rewardItem);
RaiseMessage($"You receive a {rewardItem.Name}");
}
// 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 (CurrentWeapon == null)
{
RaiseMessage("You must select a weapon, to attack.");
return;
}
// Determine damage to monster
int damageToMonster = RandomNumberGenerator.NumberBetween(CurrentWeapon.MinimumDamage, CurrentWeapon.MaximumDamage);
if (damageToMonster == 0)
{
RaiseMessage($"You missed the {CurrentMonster.Name}.");
}
else
{
RaiseMessage($"You hit the {CurrentMonster.Name} for {damageToMonster} points.");
CurrentMonster.TakeDamage(damageToMonster);
}
if(CurrentMonster.IsDead)
{
// Get another monster to fight
GetMonsterAtLocation();
}
else
{
// Let the monster attack
int damageToPlayer = RandomNumberGenerator.NumberBetween(CurrentMonster.MinimumDamage, CurrentMonster.MaximumDamage);
if (damageToPlayer == 0)
{
RaiseMessage($"The {CurrentMonster.Name} attacks, but misses you.");
}
else
{
RaiseMessage($"The {CurrentMonster.Name} hit you for {damageToPlayer} points.");
CurrentPlayer.TakeDamage(damageToPlayer);
}
}
}
private void OnCurrentPlayerKilled(object sender, System.EventArgs eventArgs)
{
RaiseMessage("");
RaiseMessage($"The {CurrentMonster.Name} killed you.");
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.ExperiencePoints += CurrentMonster.RewardExperiencePoints;
RaiseMessage($"You receive {CurrentMonster.Gold} gold.");
CurrentPlayer.ReceiveGold(CurrentMonster.Gold);
foreach(GameItem gameItem in CurrentMonster.Inventory)
{
RaiseMessage($"You receive one {gameItem.Name}.");
CurrentPlayer.AddItemToInventory(gameItem);
}
}
private void RaiseMessage(string message)
{
OnMessageRaised?.Invoke(this, new GameMessageEventArgs(message));
}
}
}
Step 4: Modify WPFUI\TradeScreen.xaml.cs
Now that the LivingEntity Gold property is encapsulated, we need to change how we add and subtract gold from the player when they sell and buy items with a trader.
Change line 26 to use the ReceiveGold function. Then, change line 41 to use the SpendGold function.
TradeScreen.xaml.cs
using System.Windows;
using Engine.Models;
using Engine.ViewModels;
namespace WPFUI
{
/// <summary>
/// Interaction logic for TradeScreen.xaml
/// </summary>
public partial class TradeScreen : Window
{
public GameSession Session => DataContext as GameSession;
public TradeScreen()
{
InitializeComponent();
}
private void OnClick_Sell(object sender, RoutedEventArgs e)
{
GroupedInventoryItem groupedInventoryItem =
((FrameworkElement)sender).DataContext as GroupedInventoryItem;
if(groupedInventoryItem != null)
{
Session.CurrentPlayer.ReceiveGold(groupedInventoryItem.Item.Price);
Session.CurrentTrader.AddItemToInventory(groupedInventoryItem.Item);
Session.CurrentPlayer.RemoveItemFromInventory(groupedInventoryItem.Item);
}
}
private void OnClick_Buy(object sender, RoutedEventArgs e)
{
GroupedInventoryItem groupedInventoryItem =
((FrameworkElement)sender).DataContext as GroupedInventoryItem;
if(groupedInventoryItem != null)
{
if(Session.CurrentPlayer.Gold >= groupedInventoryItem.Item.Price)
{
Session.CurrentPlayer.SpendGold(groupedInventoryItem.Item.Price);
Session.CurrentTrader.RemoveItemFromInventory(groupedInventoryItem.Item);
Session.CurrentPlayer.AddItemToInventory(groupedInventoryItem.Item);
}
else
{
MessageBox.Show("You do not have enough gold");
}
}
}
private void OnClick_Close(object sender, RoutedEventArgs e)
{
Close();
}
}
}
Step 5: Test the game
Because we’re refactoring, the program should run exactly the same as it did before. We’re just improving the structure of the code – making it easier for us to work with.
NEXT LESSON: Lesson 10.4: Bug Fix – Removing multiple items from GroupedInventory
PREVIOUS LESSON: Lesson 10.2: Grouping GameItems in Inventories