With a working game engine, it’s time for some changes to make it more flexible and able to support an ever-growing list of features and functions. One large refactoring that we will make is to replace the weapon and monster attack behavior with an Attack command instead. We will learn and use the Command design pattern for these types of game actions.
First, the Command design pattern is a behavioral design pattern in which an object is used to encapsulate all information needed to perform an action or trigger an event at a later time. This information includes the method name, the object that owns the method, and values for the method parameters. And typically there is an Execute
method that performs the action.
Many applications use commands to respond to user interface events (like button clicks), support multi-level undo/redo, progress indicators, and mobile app actions. We will use it to enable our GameSession
view model to perform various actions… with the first one being to attack.
In this lesson and in the future, we will use both command and action interchangeably. They will mean the same thing, but the term command is easier to map to the design pattern.
Defining the Attack Command
In order the generalize the usage of commands, we are going to define an interface that our commands will use, the IAction
interface. Our code will reference IAction
-based commands and execute those commands. Our GameSession
does not need to know the specifics of each of the commands.
We will start in the SimpleRPG.Game.Engine, and create a new folder named Actions. In that folder, let’s create the IAction
interface (same as creating a class, but uses the interface keyword).
using SimpleRPG.Game.Engine.Models;
using System;
namespace SimpleRPG.Game.Engine.Actions
{
public interface IAction
{
DisplayMessage Execute(LivingEntity actor, LivingEntity target);
}
}
Every action will just have an Execute
method that takes two parameters, the entity that is performing the action, and the target of the action. For an attack, the actor is the creature performing the attack; the target is the creature taking the resulting damage.
The Execute
method returns a DisplayMessage
that describes the result of the action. The DisplayMessage
can then be used by our presentation layer to show the result to the user.
With the interface definition in place, let’s create our first command that implements that interface. In the same folder, let’s create the Attack
action:
using SimpleRPG.Game.Engine.Models;
using SimpleRPG.Game.Engine.Services;
using System;
namespace SimpleRPG.Game.Engine.Actions
{
public class Attack : IAction
{
private readonly GameItem _itemInUse;
private readonly IDiceService _diceService;
private readonly string _damageDice;
public Attack(GameItem itemInUse, string damageDice, IDiceService? diceService = null)
{
_itemInUse = itemInUse ?? throw new ArgumentNullException(nameof(itemInUse));
_diceService = diceService ?? DiceService.Instance;
if (itemInUse.Category != GameItem.ItemCategory.Weapon)
{
throw new ArgumentException($"{itemInUse.Name} is not a weapon");
}
if (string.IsNullOrWhiteSpace(damageDice))
{
throw new ArgumentException("damageDice must be valid dice notation");
}
_damageDice = damageDice;
}
public DisplayMessage Execute(LivingEntity actor, LivingEntity target)
{
_ = actor ?? throw new ArgumentNullException(nameof(actor));
_ = target ?? throw new ArgumentNullException(nameof(target));
string actorName = (actor is Player) ? "You" : $"The {actor.Name.ToLower()}";
string targetName = (target is Player) ? "you" : $"the {target.Name.ToLower()}";
string title = (actor is Player) ? "Player Combat" : "Monster Combat";
int damage = _diceService.Roll(_damageDice).Value;
string message;
if (damage == 0)
{
message = $"{actorName} missed {targetName}.";
}
else
{
target.TakeDamage(damage);
message = $"{actorName} hit {targetName} for {damage} point{(damage > 1 ? "s" : "")}.";
}
return new DisplayMessage(title, message);
}
}
}
The Attack
class’s constructor takes the GameItem
it is attached to, the dice notation string for the damage roll, and optionally the IDiceService
(so that we can make this class testable and flexible).
Then, the Execute
method performs the bulk of the action. This code was copied from the GameSession.AttackCurrentMonster
method — the block that deals with the player attacking the monster. Then we generalized the string creation, so that it will work for when either the player or the monster attacks.
This method:
- validates that the actor and target are set.
- then calculates the actor and target names and message title based on whether the player or the monster are attacking (we use “you” to represent the player).
- then we roll the dice based on the
_damageDice
set in the constructor. - then we have the target take damage.
- finally we return the
DisplayMessage
with the results of theAttack
action.
To simplify DisplayMessage
creation in this action, we made a minor modification to DisplayMessage
to give it a constructor that just takes a title and single message string.
using System.Collections.Generic;
namespace SimpleRPG.Game.Engine.Models
{
public class DisplayMessage
{
public DisplayMessage(string title, IList<string> messages)
{
Title = title;
Messages = messages;
}
public DisplayMessage(string title, string message)
{
Title = title;
Messages = new List<string> { message };
}
public string Title { get; } = string.Empty;
public IList<string> Messages { get; }
}
}
GameItem Changes
With the Attack
action in place, we need to refactor GameItem
to have an action. For weapons, that would be the Attack
action. For minor items, there is no action. For future items, we could build other features, like a Heal
action for potions.
Rather than continue to derive different items, like Weapon
or Potion
or whatever else, we are going to make the GameItem
class more flexible by giving it a Category
(which defines the type of item) and an Action
(which handles whatever behavior the item has).
using SimpleRPG.Game.Engine.Actions;
using System;
namespace SimpleRPG.Game.Engine.Models
{
public class GameItem
{
public enum ItemCategory
{
Miscellaneous,
Weapon
}
public static readonly GameItem Empty = new GameItem();
public GameItem(int itemTypeID, ItemCategory category, string name, int price, bool isUnique = false, IAction? action = null)
{
ItemTypeID = itemTypeID;
Category = category;
Name = name;
Price = price;
IsUnique = isUnique;
Action = action;
}
public GameItem()
{
}
public int ItemTypeID { get; set; }
public ItemCategory Category { get; set; }
public string Name { get; set; } = string.Empty;
public int Price { get; set; }
public bool IsUnique { get; set; }
public IAction? Action { get; set; }
public virtual GameItem Clone() =>
new GameItem(ItemTypeID, Category, Name, Price, IsUnique);
public DisplayMessage PerformAction(LivingEntity actor, LivingEntity target)
{
if (Action is null)
{
throw new InvalidOperationException("CurrentWeapon.Action cannot be null");
}
return Action.Execute(actor, target);
}
}
}
First, we have the ItemCategory enum
which defines the different item types (lines #8-12). This enum is actually scoped within the GameItem
class, so whenever you wish use it outside of this class, you must use the fully qualified name: GameItem.ItemCategory.Weapon
.
We update the constructor and Clone
method to take the ItemCategory
and IAction
as parameters. IAction
is optional and defaults to null. If an action is not specified, then the item does not have one.
Then we define properties for: Category
(line #32) and Action
(line #43).
Finally, we have the PerformAction
method which verifies that an action is defined on this item. Then it calls the IAction.Execute
method… this method simplifies how the game engine interacts with the action.
Now, we can delete the Weapon
class because we will no longer use it. Instead we will define the item with a category of Weapon
. The changes to GameItem
and deletion of the Weapon
class will cause breaks in many places in our code, so we need to fix those up next.
Let’s start with fixing up the Inventory
class. Its Weapons
property needs to be rewritten to use the enum rather than the item’s type (lines #33-34):
using System;
using System.Collections.Generic;
using System.Linq;
namespace SimpleRPG.Game.Engine.Models
{
public class Inventory
{
private readonly List<GameItem> _backingInventory = new List<GameItem>();
private readonly List<GroupedInventoryItem> _backingGroupedInventory = new List<GroupedInventoryItem>();
public Inventory(IEnumerable<GameItem> items)
{
if (items == null)
{
return;
}
foreach (GameItem item in items)
{
AddItem(item);
}
}
public Inventory()
{
}
public IReadOnlyList<GameItem> Items => _backingInventory.AsReadOnly();
public IReadOnlyList<GroupedInventoryItem> GroupedItems => _backingGroupedInventory.AsReadOnly();
public IList<GameItem> Weapons =>
Items.Where(i => i.Category == GameItem.ItemCategory.Weapon).ToList();
public void AddItem(GameItem item)
{
_ = item ?? throw new ArgumentNullException(nameof(item));
_backingInventory.Add(item);
if (item.IsUnique)
{
_backingGroupedInventory.Add(new GroupedInventoryItem { Item = item, Quantity = 1 });
}
else
{
if (_backingGroupedInventory.All(gi => gi.Item.ItemTypeID != item.ItemTypeID))
{
_backingGroupedInventory.Add(new GroupedInventoryItem { Item = item, Quantity = 0 });
}
_backingGroupedInventory.First(gi => gi.Item.ItemTypeID == item.ItemTypeID).Quantity++;
}
}
public void RemoveItem(GameItem item)
{
_ = item ?? throw new ArgumentNullException(nameof(item));
_backingInventory.Remove(item);
GroupedInventoryItem groupedInventoryItemToRemove =
_backingGroupedInventory.FirstOrDefault(gi => gi.Item == item);
if (groupedInventoryItemToRemove != null)
{
if (groupedInventoryItemToRemove.Quantity == 1)
{
_backingGroupedInventory.Remove(groupedInventoryItemToRemove);
}
else
{
groupedInventoryItemToRemove.Quantity--;
}
}
}
public bool HasAllTheseItems(IEnumerable<ItemQuantity> items)
{
return items.All(item => Items.Count(i => i.ItemTypeID == item.ItemId) >= item.Quantity);
}
}
}
Then the ItemFactory is rewritten to handle how weapons and miscellaneous items are created.
using SimpleRPG.Game.Engine.Actions;
using SimpleRPG.Game.Engine.Models;
using SimpleRPG.Game.Engine.Services;
using System.Collections.Generic;
using System.Linq;
namespace SimpleRPG.Game.Engine.Factories
{
internal static class ItemFactory
{
private static readonly List<GameItem> _standardGameItems = new List<GameItem>();
static ItemFactory()
{
BuildWeapon(1001, "Pointy Stick", 1, "1d2");
BuildWeapon(1002, "Rusty Sword", 5, "1d3");
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)
{
var standardItem = _standardGameItems.First(i => i.ItemTypeID == itemTypeID);
return standardItem.Clone();
}
private static void BuildMiscellaneousItem(int id, string name, int price)
{
_standardGameItems.Add(new GameItem(id, GameItem.ItemCategory.Miscellaneous, name, price));
}
private static void BuildWeapon(int id, string name, int price, string damageDice)
{
var weapon = new GameItem(id, GameItem.ItemCategory.Weapon, name, price, true);
weapon.Action = new Attack(weapon, damageDice);
_standardGameItems.Add(weapon);
}
}
}
The ItemFactory
now uses BuildMiscellaneousItem
and BuildWeapon
to create the item with an action, when it is required. The implementation for this class was significantly changed, so it would be useful to review it in detail. But it’s behavior is the same as before.
Refactoring GameSession and CombatComponent
We continue with our changes up our layered design and into the ViewModel. First, we need to remove any reference from the Weapon
class in the IGameSession.AttackCurrentMonster
definition:
using SimpleRPG.Game.Engine.Models;
using System.Collections.Generic;
namespace SimpleRPG.Game.Engine.ViewModels
{
public interface IGameSession
{
Player CurrentPlayer { get; }
Location CurrentLocation { get; }
Monster? CurrentMonster { get; }
bool HasMonster { get; }
Trader? CurrentTrader { get; }
MovementUnit Movement { get; }
IList<DisplayMessage> Messages { get; }
void OnLocationChanged(Location newLocation);
void AttackCurrentMonster(GameItem? currentWeapon);
}
}
Then, also update the method signature in the GameSession
class:
using SimpleRPG.Game.Engine.Factories;
using SimpleRPG.Game.Engine.Models;
using SimpleRPG.Game.Engine.Services;
using System;
using System.Collections.Generic;
using System.Linq;
namespace SimpleRPG.Game.Engine.ViewModels
{
public class GameSession : IGameSession
{
private readonly World _currentWorld;
private readonly IDiceService _diceService;
private readonly int _maximumMessagesCount = 100;
public Player CurrentPlayer { get; private set; }
public Location CurrentLocation { get; private set; }
public Monster? CurrentMonster { get; private set; }
public bool HasMonster => CurrentMonster != null;
public Trader? CurrentTrader { get; private set; }
public MovementUnit Movement { get; private set; }
public IList<DisplayMessage> Messages { get; } = new List<DisplayMessage>();
public GameSession(int maxMessageCount)
: this()
{
_maximumMessagesCount = maxMessageCount;
}
public GameSession(IDiceService? diceService = null)
{
_diceService = diceService ?? DiceService.Instance;
CurrentPlayer = new Player
{
Name = "DarthPedro",
CharacterClass = "Fighter",
CurrentHitPoints = 10,
MaximumHitPoints = 10,
Gold = 1000,
Level = 1
};
_currentWorld = WorldFactory.CreateWorld();
Movement = new MovementUnit(_currentWorld);
CurrentLocation = Movement.CurrentLocation;
GetMonsterAtCurrentLocation();
if (!CurrentPlayer.Inventory.Weapons.Any())
{
CurrentPlayer.Inventory.AddItem(ItemFactory.CreateGameItem(1001));
}
}
public void OnLocationChanged(Location newLocation)
{
_ = newLocation ?? throw new ArgumentNullException(nameof(newLocation));
CurrentLocation = newLocation;
Movement.UpdateLocation(CurrentLocation);
GetMonsterAtCurrentLocation();
CompleteQuestsAtLocation();
GetQuestsAtLocation();
CurrentTrader = CurrentLocation.TraderHere;
}
public void AttackCurrentMonster(GameItem? currentWeapon)
{
if (CurrentMonster is null)
{
return;
}
if (currentWeapon is null)
{
AddDisplayMessage("Combat Warning", "You must select a weapon, to attack.");
return;
}
// player acts monster with weapon
var message = currentWeapon.PerformAction(CurrentPlayer, CurrentMonster);
Messages.Add(message);
// If monster if killed, collect rewards and loot
if (CurrentMonster.IsDead)
{
var messageLines = new List<string>();
messageLines.Add($"You defeated the {CurrentMonster.Name}!");
CurrentPlayer.AddExperience(CurrentMonster.RewardExperiencePoints);
messageLines.Add($"You receive {CurrentMonster.RewardExperiencePoints} experience points.");
CurrentPlayer.ReceiveGold(CurrentMonster.Gold);
messageLines.Add($"You receive {CurrentMonster.Gold} gold.");
foreach (GameItem item in CurrentMonster.Inventory.Items)
{
CurrentPlayer.Inventory.AddItem(item);
messageLines.Add($"You received {item.Name}.");
}
AddDisplayMessage("Monster Defeated", messageLines);
// Get another monster to fight
GetMonsterAtCurrentLocation();
}
else
{
// If monster is still alive, let the monster attack
int damageToPlayer = _diceService.Roll(CurrentMonster.DamageRoll).Value;
if (damageToPlayer == 0)
{
AddDisplayMessage("Monster Combat", "The monster attacks, but misses you.");
}
else
{
CurrentPlayer.TakeDamage(damageToPlayer);
AddDisplayMessage("Monster Combat", $"The {CurrentMonster.Name} hit you for {damageToPlayer} points.");
}
// If player is killed, move them back to their home.
if (CurrentPlayer.IsDead)
{
AddDisplayMessage("Player Defeated", $"The {CurrentMonster.Name} killed you.");
CurrentPlayer.CompletelyHeal(); // Completely heal the player
this.OnLocationChanged(_currentWorld.LocationAt(0, -1)); // Return to Player's home
}
}
}
private void GetMonsterAtCurrentLocation()
{
CurrentMonster = CurrentLocation.HasMonster() ? CurrentLocation.GetMonster() : null;
if (CurrentMonster != null)
{
AddDisplayMessage("Monster Encountered:", $"You see a {CurrentMonster.Name} here!");
}
}
private void GetQuestsAtLocation()
{
foreach (Quest quest in CurrentLocation.QuestsAvailableHere)
{
if (!CurrentPlayer.Quests.Any(q => q.PlayerQuest.Id == quest.Id))
{
CurrentPlayer.Quests.Add(new QuestStatus(quest));
var messageLines = new List<string>
{
quest.Description,
"Items to complete the quest:"
};
foreach (ItemQuantity q in quest.ItemsToComplete)
{
messageLines.Add($" {ItemFactory.CreateGameItem(q.ItemId).Name} (x{q.Quantity})");
}
messageLines.Add("Rewards for quest completion:");
messageLines.Add($" {quest.RewardExperiencePoints} experience points");
messageLines.Add($" {quest.RewardGold} gold");
foreach (ItemQuantity itemQuantity in quest.RewardItems)
{
messageLines.Add($" {itemQuantity.Quantity} {ItemFactory.CreateGameItem(itemQuantity.ItemId).Name} (x{itemQuantity.Quantity})");
}
AddDisplayMessage($"Quest Added - {quest.Name}", messageLines);
}
}
}
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))
{
// 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.Inventory.RemoveItem(
CurrentPlayer.Inventory.Items.First(
item => item.ItemTypeID == itemQuantity.ItemId));
}
}
// give the player the quest rewards
var messageLines = new List<string>();
CurrentPlayer.AddExperience(quest.RewardExperiencePoints);
messageLines.Add($"You receive {quest.RewardExperiencePoints} experience points");
CurrentPlayer.ReceiveGold(quest.RewardGold);
messageLines.Add($"You receive {quest.RewardGold} gold");
foreach (ItemQuantity itemQuantity in quest.RewardItems)
{
GameItem rewardItem = ItemFactory.CreateGameItem(itemQuantity.ItemId);
CurrentPlayer.Inventory.AddItem(rewardItem);
messageLines.Add($"You receive a {rewardItem.Name}");
}
AddDisplayMessage($"Quest Completed - {quest.Name}", messageLines);
// mark the quest as completed
questToComplete.IsCompleted = true;
}
}
}
}
private void AddDisplayMessage(string title, string message) =>
AddDisplayMessage(title, new List<string> { message });
private void AddDisplayMessage(string title, IList<string> messages)
{
var message = new DisplayMessage(title, messages);
this.Messages.Insert(0, message);
if (Messages.Count > _maximumMessagesCount)
{
Messages.Remove(Messages.Last());
}
}
}
}
Then we removed the player attack code and replaced it with a call to the player’s CurrentWeapon.PerformAction
method (lines #87-89). Since we copied the combat action from here into the Attack
action, we had to remove it from here now. We just allow the action to take care of that logic and just return us the resulting text to be displayed.
Finally, in the CombatComponent
there were a few references to the Weapon
class type that also need to be fixed.
<Heading Size="HeadingSize.Is5" Margin="Margin.Is2.OnY">Combat</Heading>
<Row Margin="Margin.Is2.OnY">
<Column ColumnSize="ColumnSize.Is6.Is2.WithOffset">
<Select id="weapons-select" TValue="int">
@foreach (GameItem weapon in WeaponList)
{
<SelectItem Value="@weapon.ItemTypeID">@weapon.Name</SelectItem>
}
</Select>
</Column>
<Column ColumnSize="ColumnSize.Is2">
<Button id="attack-monster-btn" Color="Color.Secondary" Margin="Margin.Is1"
Disabled="@disableAttack" Clicked="OnAttackClicked">
Attack!
</Button>
</Column>
</Row>
@code {
private bool disableAttack => !WeaponList.Any() || LocationHasMonster == false;
private int selectedWeaponId;
[Parameter]
public IEnumerable<GameItem> WeaponList { get; set; } = Array.Empty<GameItem>();
[Parameter]
public bool LocationHasMonster { get; set; } = false;
[Parameter]
public EventCallback<GameItem?> AttackClicked { get; set; }
protected override void OnInitialized()
{
selectedWeaponId = WeaponList.Any() ? WeaponList.First().ItemTypeID : 0;
}
public void OnAttackClicked()
{
var weapon = selectedWeaponId > 0 ? WeaponList.First(f => f.ItemTypeID == selectedWeaponId) : null;
AttackClicked.InvokeAsync(weapon);
}
}
These changes all replace Weapon
with GameItem
. And when we need to differentiate, we can use GameItem.Category
to figure out its type. But since we updated the Inventory.Weapons
property to handle that logic, this component code is very ItemCategory
agnostic already.
Once again, if we rebuild and run our game, everything should work as expected in the gameplay. We changed the underlying code, but the combat functionality should remain the same.
A Quick Word About Tests
Having all of our unit tests in place gives us confidence when making large refactoring changes like this. Since our tests cover all of the behavior of the game, we can verify that our changes continue to work as expected.
Along with the code changes above, there were a lot of code changes to tests as well. Removing a class (like Weapon
) is a disruptive and breaking change. So we needed to update all of our test code as well. This may seem like a lot of work to keep the tests running, but it is necessary to keep our confidence in the system that we have written. When all of the coding changes are made and the tests all pass again, we can be confident that our changes had the expected behavior.
Sometimes as we make these types of changes, the behavior of the system and tests are expected to change. In those cases, the tests of the previous behavior will fail, but that is expected. We will fix those tests to conform to the new expected behavior and validate that it now works correctly.
Validating code refactorings is one of the big benefits of have a large, robust unit test suite that fully covers the functionality of our game. It gives us a gauge of how much work is left to complete the refactoring. And, allows us to more quickly test the game without having to manually go through each feature.
In conclusion, we defined the IAction
interface that will be used for many of our commands. Then, we built that Attack
action as our first command. Refactored all of our code to use the action and remove the Weapon
class. And finally, changed the player attack in GameSession
to use the Attack
action.
In our next lesson, we’re going to refactor the Monster
class and its combat operation to extend the use of the Attack
action.
2 thoughts on “Lesson 4.2: Refactoring to Use Attack Command”