In this lesson, we’ll build the first class that uses the Command Design Pattern – a class to handle the player attacking the current monster.
Step 1: Create Engine\Actions\AttackWithWeapon.cs
In the Engine project, create a new Actions folder and create a new class in it named AttackWithWeapon.cs. This will be a “command” class, from the Command design pattern.
Add the public event “OnActionPerformed” (line 12). We’ll use this event to notify the UI of any messages that result from executing this command object. The “ReportResult” function (lines 51-54) is the function that raises the event notification – if anything subscribed to the OnActionPerformed event.
In the constructor, we check that the parameters are valid and then save them to private variables.
The “Execute” function (lines 36-49) is the code to run when we want to execute this command. It accepts two parameters – the “actor” (who is performing the action) and the “target” (who is having the action done to them).
The code inside Execute is basically the combat code we had in the GameSession class when the player attacked the CurrentMonster.
We have some unused parameters in this class: “weapon” in the constructor and “actor” in the Execute function. We’ll use those in the future, as we make the combat logic more complex.
NOTE: On line 46, we raise the event that the UI will eventually display – before applying the damage to the monster. This is another one of those situations to watch for when usng events. The TakeDamage function could result in killing the monster, which raises an OnKilled event.
We want to be sure our “You hit the monster for X damage” message gets to the UI before the OnKilled event does. That’s why we do the action’s ReportResult notification before applying the damage to the monster.
AttackWithWeapon.cs
using System;
using Engine.Models;
namespace Engine.Actions
{
public class AttackWithWeapon
{
private readonly GameItem _weapon;
private readonly int _maximumDamage;
private readonly int _minimumDamage;
public event EventHandler<string> OnActionPerformed;
public AttackWithWeapon(GameItem weapon, int minimumDamage, int maximumDamage)
{
if(weapon.Category != GameItem.ItemCategory.Weapon)
{
throw new ArgumentException($"{weapon.Name} is not a weapon");
}
if(_minimumDamage < 0)
{
throw new ArgumentException("minimumDamage must be 0 or larger");
}
if(_maximumDamage < _minimumDamage)
{
throw new ArgumentException("maximumDamage must be >= minimumDamage");
}
_weapon = weapon;
_minimumDamage = minimumDamage;
_maximumDamage = maximumDamage;
}
public void Execute(LivingEntity actor, LivingEntity target)
{
int damage = RandomNumberGenerator.NumberBetween(_minimumDamage, _maximumDamage);
if(damage == 0)
{
ReportResult($"You missed the {target.Name.ToLower()}.");
}
else
{
ReportResult($"You hit the {target.Name.ToLower()} for {damage} points.");
target.TakeDamage(damage);
}
}
private void ReportResult(string result)
{
OnActionPerformed?.Invoke(this, result);
}
}
}
Step 2: Modify Engine\Models\GameItem.cs
Remove the MinimumDamage and MaximumDamage properties from the class – including from the constructor and Clone function.
Add a new “Action” property whose datatype is “AttackWithWeapon”. Add this new property to the constructor and Clone function.
Add the new PerformAction function (lines 31-34). When the player uses their CurrentWeapon, this is the function we will call.
GameItem.cs
using Engine.Actions;
namespace Engine.Models
{
public class GameItem
{
public enum ItemCategory
{
Miscellaneous,
Weapon
}
public ItemCategory Category { get; }
public int ItemTypeID { get; }
public string Name { get; }
public int Price { get; }
public bool IsUnique { get; }
public AttackWithWeapon Action { get; set; }
public GameItem(ItemCategory category, int itemTypeID, string name, int price,
bool isUnique = false, AttackWithWeapon action = null)
{
Category = category;
ItemTypeID = itemTypeID;
Name = name;
Price = price;
IsUnique = isUnique;
Action = action;
}
public void PerformAction(LivingEntity actor, LivingEntity target)
{
Action?.Execute(actor, target);
}
public GameItem Clone()
{
return new GameItem(Category, ItemTypeID, Name, Price, IsUnique, Action);
}
}
}
Step 3: Modify Engine\Factories\ItemFactory.cs
Since we changed the constructor for the GameItem class, we need to change the ItemFactory.
First, on line 38, we’ll remove the minimumDamage and maximumDamage parameters from the constructor call and store the GameItem in a variable.
On line 40, we’ll create an AttackWithWeapon object and set it to the weapon’s Action property. We need to have these on two separate lines because the AttackWithWeapon constructor needs the weapon passed in as a parameter.
NOTE: To use the AttackWithWeapon class, you need to add the “using Engine.Actions” statement on line 3.
ItemFactory.cs
using System.Collections.Generic;
using System.Linq;
using Engine.Actions;
using Engine.Models;
namespace Engine.Factories
{
public static class ItemFactory
{
private static readonly List<GameItem> _standardGameItems = new List<GameItem>();
static ItemFactory()
{
BuildWeapon(1001, "Pointy Stick", 1, 1, 2);
BuildWeapon(1002, "Rusty Sword", 5, 1, 3);
BuildMiscellaneousItem(9001, "Snake fang", 1);
BuildMiscellaneousItem(9002, "Snakeskin", 2);
BuildMiscellaneousItem(9003, "Rat tail", 1);
BuildMiscellaneousItem(9004, "Rat fur", 2);
BuildMiscellaneousItem(9005, "Spider fang", 1);
BuildMiscellaneousItem(9006, "Spider silk", 2);
}
public static GameItem CreateGameItem(int itemTypeID)
{
return _standardGameItems.FirstOrDefault(item => item.ItemTypeID == itemTypeID)?.Clone();
}
private static void BuildMiscellaneousItem(int id, string name, int price)
{
_standardGameItems.Add(new GameItem(GameItem.ItemCategory.Miscellaneous, id, name, price));
}
private static void BuildWeapon(int id, string name, int price,
int minimumDamage, int maximumDamage)
{
GameItem weapon = new GameItem(GameItem.ItemCategory.Weapon, id, name, price, true);
weapon.Action = new AttackWithWeapon(weapon, minimumDamage, maximumDamage);
_standardGameItems.Add(weapon);
}
}
}
Step 4: Modify Engine\Models\LivingEntity.cs
First, we’ll add an “OnActionPerformed” event on line 101. The UI will watch this event for any messages that are raised when the LivingEntity performs an action. The LivingEntity watches the AttackWithWeapon class for any messages. If it sees one, LivingEntity will raise an event that passes the AttackWithWeapon message to anything subscribed to the LivingEntity’s OnActionPerformed event.
You can think of this as an employee notifying their manager of something, and the manager notifying their boss.
Next, because we are changing the program to use The AttackWIthWeapon “Action” property on the GameItem class, we need to let the monsters have a weapon. This means we need to move the CurrentWeapon from the GameSession class (where it only applies to the CurrentPlayer) into the LivingEntity class. This way, Monsters and Traders will also have use the a CurrentWeapon.
Add the “_currentWeapon” backing variable on line 17. Then, add the CurrentWeapon property setter and getter on lines 69-88. Inside the setter, subscribe to (and unsubscribe from) the current weapon’ action message event. This is where the player (or monster/trader) watches for events raised by their weapon’s action.
Create the “RaiseActionPerformedEvent” function on lines 214-218, to pass the weapon’s message up to the UI – which is only watching for events on the LivingEntity, not the LivingEntity’s CurrentWeapon.
Finally, create the “UseCurrentWeaponOn” function on lines 117-120. This is just a simple wrapper function that the ViewModel will use to initiate an attack. We could eliminate this function, and have the ViewModel directly call CurrentPlayer.CurrentWeapon.PerformAction(CurrentPlayer, CurrentMonster). But, I think this makes the code a little cleaner.
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;
private int _level;
private GameItem _currentWeapon;
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 GameItem CurrentWeapon
{
get { return _currentWeapon; }
set
{
if(_currentWeapon != null)
{
_currentWeapon.Action.OnActionPerformed -= RaiseActionPerformedEvent;
}
_currentWeapon = value;
if (_currentWeapon != null)
{
_currentWeapon.Action.OnActionPerformed += RaiseActionPerformedEvent;
}
OnPropertyChanged();
}
}
public ObservableCollection<GameItem> Inventory { get; }
public ObservableCollection<GroupedInventoryItem> GroupedInventory { get; }
public List<GameItem> Weapons =>
Inventory.Where(i => i.Category == GameItem.ItemCategory.Weapon).ToList();
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 ObservableCollection<GameItem>();
GroupedInventory = new ObservableCollection<GroupedInventoryItem>();
}
public void UseCurrentWeaponOn(LivingEntity target)
{
CurrentWeapon.PerformAction(this, target);
}
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 = item.IsUnique ?
GroupedInventory.FirstOrDefault(gi => gi.Item == item) :
GroupedInventory.FirstOrDefault(gi => gi.Item.ItemTypeID == item.ItemTypeID);
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());
}
private void RaiseActionPerformedEvent(object sender, string result)
{
OnActionPerformed?.Invoke(this, result);
}
#endregion
}
}
Step 5: Modify Engine\ViewModels\GameSession.cs
Remove the CurrentWeapon property from line 101 (in the original code).
Because we now watch for action results as LivingEntity OnActionPerformed events, we need to subscribe to the event and create a handler in the ViewModel.
Create OnCurrentPlayerPerformedAction (lines 279-282). Subscribe to it on line 38 and unsubscribe from it on line 29.
Change the AttackCurrentMoster function to use the new action.
Change line 249 to check CurrentPlayer.CurrentWeapon – since it is not a property in GameSession.
Change the existing code that handles the player attacking the monster (lines 255-266, in the original code) to the new line 255.
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.OnKilled -= OnCurrentMonsterKilled;
}
_currentMonster = value;
if(_currentMonster != null)
{
_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.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
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(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
{
// 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 OnCurrentPlayerPerformedAction(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)
{
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 6: Modify WPFUI\MainWindow.xaml
Update the player’s weapon combobox to bind its SelectedItem to “CurrentPlayer.CurrentWeapon” (line 234), instead of the “CurrentWeapon” that used to exist in the GameSession class.
MainWindow.xaml (lines 232-236)
<ComboBox Grid.Row="0" Grid.Column="0"
ItemsSource="{Binding CurrentPlayer.Weapons}"
SelectedItem="{Binding CurrentPlayer.CurrentWeapon}"
DisplayMemberPath="Name"
SelectedValuePath="ID"/>
Step 7: Run the test and the game, to ensure it still works.
In the next lesson, we’ll make changes to allow us to handle more actions and make the AttackWithWeapon action more flexible – so the monsters can use it too.
NEXT LESSON: Lesson 12.3: Making the Action class more flexible with an interface
PREVIOUS LESSON: Lesson 12.1: Making the GameItem class more flexible