In this lesson, we are going to focus on refactoring our combat functionality out of the GameSession
view model. There are various methods that deal with combat and handle either player or monster deaths, so it would be helpful to encapsulate all of the combat related code into its own Battle
class. We are also going to expand the combat in the next lesson, so having all of that code in one cohesive class rather than within the view model will make it easier to understand, change, and test.
Implementing the Battle class
Let’s create the Battle
class in the SimpleRPG.Game.Engine project and Models folder.
using SimpleRPG.Game.Engine.Services;
using System;
using System.Collections.Generic;
namespace SimpleRPG.Game.Engine.Models
{
public class Battle
{
private readonly DisplayMessageBroker _messageBroker = DisplayMessageBroker.Instance;
private readonly Action _onPlayerKilled;
private readonly Action _onOpponentKilled;
public Battle(Action onPlayerKilled, Action onOpponenetKilled)
{
_onPlayerKilled = onPlayerKilled;
_onOpponentKilled = onOpponenetKilled;
}
public void Attack(Player player, Monster opponent)
{
_ = player ?? throw new ArgumentNullException(nameof(player));
_ = opponent ?? throw new ArgumentNullException(nameof(opponent));
AttackOpponent(player, opponent);
}
private void AttackOpponent(Player player, Monster opponent)
{
if (player.CurrentWeapon == null)
{
_messageBroker.RaiseMessage(
new DisplayMessage("Combat Warning", "You must select a weapon, to attack."));
return;
}
// player acts monster with weapon
var message = player.UseCurrentWeaponOn(opponent);
_messageBroker.RaiseMessage(message);
// if monster is killed, collect rewards and loot
if (opponent.IsDead)
{
OnOpponentKilled(player, opponent);
}
else
{
// if the monster is still alive, it attacks the player.
AttackPlayer(player, opponent);
}
}
private void AttackPlayer(Player player, Monster opponent)
{
// now the monster attacks the player
var message = opponent.UseCurrentWeaponOn(player);
_messageBroker.RaiseMessage(message);
// if player is killed, move them back to their home and heal.
if (player.IsDead)
{
OnPlayerKilled(player, opponent);
}
}
private void OnPlayerKilled(Player player, Monster opponent)
{
_messageBroker.RaiseMessage(
new DisplayMessage("Player Defeated", $"The {opponent.Name} killed you."));
player.CompletelyHeal(); // Completely heal the player.
_onPlayerKilled.Invoke(); // Action to reset player to home location.
}
private void OnOpponentKilled(Player player, Monster opponent)
{
var messageLines = new List<string>();
messageLines.Add($"You defeated the {opponent.Name}!");
player.AddExperience(opponent.RewardExperiencePoints);
messageLines.Add($"You receive {opponent.RewardExperiencePoints} experience points.");
player.ReceiveGold(opponent.Gold);
messageLines.Add($"You receive {opponent.Gold} gold.");
foreach (GameItem item in opponent.Inventory.Items)
{
player.Inventory.AddItem(item);
messageLines.Add($"You received {item.Name}.");
}
_messageBroker.RaiseMessage(new DisplayMessage("Monster Defeated", messageLines));
_onOpponentKilled.Invoke(); // Action to get another opponent.
}
}
}
To implement the Battle
class, we pulled the following methods from the GameSession
class: AttackOpponent
(which was renamed from AttackCurrentMonster
), AttackPlayer
, OnPlayerKilled
and OnOpponentKilled
. As we can see the combat code follows the same logic: the player attacks the monster; checks if the monster is dead and respawns; if the monster is not dead, then it attacks the player; and if the player dies, we reset their hit points and move them back home.
To create the Battle
class, we did refactor how these methods were implemented, so let’s review the changes in this class:
- We define a message broker member to use throughout the class (line #9). The
DisplayMessageBroker
was implemented inlesson 4.8
. - Rather than using events, we define two actions provided by the caller (lines #10-17). The
Action
class in C# represents a delegate that has void return type and optional parameters – for our use they have no parameters. We have two actions: one to respond when the player is killed (onPlayerKilled
) or when the opponent is killed (onOpponentKilled
). - We define a public
Attack
method (lines #19-25) that is called from theGameSession
. At this point, it only checks the player and opponent parameters and then calls theAttackOpponent
method. - The remaining methods work just like they did in the
GameSession
, expect that they use the player and opponent as parameters rather than accessing theGameSession.CurrentPlayer
andCurrentMonster
. Then, we replaced all of theAddDisplayMessage
calls with the_messageBroker.RaiseMessage
call. - In line #71, we invoke the
_onPlayerKilled
action/delegate to let theGameSession
move the player back home. We use this delegate callback because we cannot and don’t want to control the current game location from thisBattle
class. - In line #93, we invoke the
_onOpponentKilled
action/delegate to let theGameSession
respawn an monster at this location. Again, this is functionality the view model should be responsible for.
The Battle
class is a fairly big design change, but the combat behavior remains the same. We are doing this refactoring step first without introducing new changes, so that we can use our tests to validate that we didn’t break the combat functionality. When refactoring code, it is usually best to try not to make multiple changes, so that you can isolate unexpected different behaviors.
We are also going to make a simple change to the LivingEntity
class to add an IsAlive
property, which is an inverse of the IsDead
property currently in that class (lines #27-29).
using System;
namespace SimpleRPG.Game.Engine.Models
{
public abstract class LivingEntity
{
public string Name { get; set; } = string.Empty;
public int CurrentHitPoints { get; set; }
public int MaximumHitPoints { get; set; }
public int Gold { get; set; }
public int Level { get; set; }
public Inventory Inventory { get; } = new Inventory();
public GameItem? CurrentWeapon { get; set; }
public bool HasCurrentWeapon => CurrentWeapon != null;
public GameItem? CurrentConsumable { get; set; }
public bool HasCurrentConsumable => CurrentConsumable != null;
public bool IsAlive => CurrentHitPoints > 0;
public bool IsDead => !IsAlive;
public void TakeDamage(int hitPointsOfDamage)
{
if (hitPointsOfDamage > 0)
{
CurrentHitPoints -= hitPointsOfDamage;
}
}
public DisplayMessage UseCurrentWeaponOn(LivingEntity target)
{
if (CurrentWeapon is null)
{
throw new InvalidOperationException("CurrentWeapon cannot be null.");
}
return CurrentWeapon.PerformAction(this, target);
}
public DisplayMessage UseCurrentConsumable(LivingEntity target)
{
if (CurrentConsumable is null)
{
throw new InvalidOperationException("CurrentConsumable cannot be null.");
}
Inventory.RemoveItem(CurrentConsumable);
return CurrentConsumable.PerformAction(this, target);
}
public void Heal(int hitPointsToHeal)
{
if (hitPointsToHeal > 0)
{
CurrentHitPoints += hitPointsToHeal;
if (CurrentHitPoints > MaximumHitPoints)
{
CurrentHitPoints = MaximumHitPoints;
}
}
}
public void CompletelyHeal()
{
CurrentHitPoints = MaximumHitPoints;
}
public void ReceiveGold(int amountOfGold)
{
if (amountOfGold > 0)
{
Gold += amountOfGold;
}
}
public void SpendGold(int amountOfGold)
{
if (amountOfGold > Gold)
{
throw new ArgumentOutOfRangeException(nameof(amountOfGold), $"{Name} only has {Gold} gold, and cannot spend {amountOfGold} gold");
}
if (amountOfGold > 0)
{
Gold -= amountOfGold;
}
}
}
}
Simplifying the GameSession View Model
Now that all of the combat code is in the Battle
class, we can remove that code from the GameSession
. It will greatly simplify this class.
using SimpleRPG.Game.Engine.Factories;
using SimpleRPG.Game.Engine.Models;
using System;
using System.Collections.Generic;
using System.Linq;
namespace SimpleRPG.Game.Engine.ViewModels
{
public class GameSession : IGameSession
{
private readonly World _currentWorld;
private readonly Battle _battle;
private readonly int _maximumMessagesCount = 100;
private readonly Dictionary<string, Action> _userInputActions = new Dictionary<string, Action>();
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()
{
InitializeUserInputActions();
_battle = new Battle(
() => OnLocationChanged(_currentWorld.GetHomeLocation()), // Return to Player's home
() => GetMonsterAtCurrentLocation()); // Gets another monster
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));
}
CurrentPlayer.Inventory.AddItem(ItemFactory.CreateGameItem(2001));
CurrentPlayer.LearnRecipe(RecipeFactory.GetRecipeById(1));
CurrentPlayer.Inventory.AddItem(ItemFactory.CreateGameItem(3001));
CurrentPlayer.Inventory.AddItem(ItemFactory.CreateGameItem(3002));
CurrentPlayer.Inventory.AddItem(ItemFactory.CreateGameItem(3003));
}
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 != null)
{
CurrentPlayer.CurrentWeapon = currentWeapon;
_battle.Attack(CurrentPlayer, CurrentMonster);
}
}
public void ConsumeCurrentItem(GameItem? item)
{
if (item is null || item.Category != GameItem.ItemCategory.Consumable)
{
AddDisplayMessage("Item Warning", "You must select a consumable item to use.");
return;
}
// player uses consumable item to heal themselves and item is removed from inventory.
CurrentPlayer.CurrentConsumable = item;
var message = CurrentPlayer.UseCurrentConsumable(CurrentPlayer);
AddDisplayMessage(message);
}
public void CraftItemUsing(Recipe recipe)
{
_ = recipe ?? throw new ArgumentNullException(nameof(recipe));
var lines = new List<string>();
if (CurrentPlayer.Inventory.HasAllTheseItems(recipe.Ingredients))
{
CurrentPlayer.Inventory.RemoveItems(recipe.Ingredients);
foreach (ItemQuantity itemQuantity in recipe.OutputItems)
{
for (int i = 0; i < itemQuantity.Quantity; i++)
{
GameItem outputItem = ItemFactory.CreateGameItem(itemQuantity.ItemId);
CurrentPlayer.Inventory.AddItem(outputItem);
lines.Add($"You craft 1 {outputItem.Name}");
}
}
AddDisplayMessage("Item Creation", lines);
}
else
{
lines.Add("You do not have the required ingredients:");
foreach (ItemQuantity itemQuantity in recipe.Ingredients)
{
lines.Add($" {itemQuantity.Quantity} {ItemFactory.GetItemName(itemQuantity.ItemId)}");
}
AddDisplayMessage("Item Creation", lines);
}
}
public void ProcessKeyPress(KeyProcessingEventArgs args)
{
_ = args ?? throw new ArgumentNullException(nameof(args));
var key = args.Key.ToUpper();
if (_userInputActions.ContainsKey(key))
{
_userInputActions[key].Invoke();
}
}
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));
AddDisplayMessage(quest.ToDisplayMessage());
}
}
}
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
CurrentPlayer.Inventory.RemoveItems(quest.ItemsToComplete);
// 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 InitializeUserInputActions()
{
_userInputActions.Add("W", () => Movement.MoveNorth());
_userInputActions.Add("A", () => Movement.MoveWest());
_userInputActions.Add("S", () => Movement.MoveSouth());
_userInputActions.Add("D", () => Movement.MoveEast());
_userInputActions.Add("ARROWUP", () => Movement.MoveNorth());
_userInputActions.Add("ARROWLEFT", () => Movement.MoveWest());
_userInputActions.Add("ARROWDOWN", () => Movement.MoveSouth());
_userInputActions.Add("ARROWRIGHT", () => Movement.MoveEast());
}
private void AddDisplayMessage(string title, string message) =>
AddDisplayMessage(title, new List<string> { message });
private void AddDisplayMessage(string title, IList<string> messages)
{
AddDisplayMessage(new DisplayMessage(title, messages));
}
public void AddDisplayMessage(DisplayMessage message)
{
this.Messages.Insert(0, message);
if (Messages.Count > _maximumMessagesCount)
{
Messages.Remove(Messages.Last());
}
}
}
}
We deleted these methods and code from this class: AttackCurrentMonster
, OnCurrentPlayerKilled
, and OnCurrentMonsterKilled
.
Then we added the following new changes:
- Define the
_battle
member for the view model (line #12). - Create an instance of the
Battle
class in theGameSession
constructor (lines #39-41). In this code, we can see:- the first action (
onPlayerKilled
) maps to a call toOnLocationChanged
method to the home location. - the second action (
onOpponentKilled
) maps to theGetMonsterAtCurrentLocation
method, which gets a fresh monster found at this location.
- the first action (
- The
AttackCurrentMonster
method now checks that we have aCurrentMonster
at this location, sets theCurrentPlayer.CurrentWeapon
to the weapon passed to this method, and calls the_battle.Attack
method with theCurrentPlayer
and theCurrentMonster
.
With these changes to our view model, we have completed the design change. The code should build correctly again. If we run the game again, we shouldn’t see any combat behavior differences.
Since the Battle
class is now separated out, we can build unit tests that exercise just the combat portions. To complete this work, we defined the BattleTests
class that validates the different aspects of combat… player attack, monster attack, monster death, and player death. To review the test changes, please look at the commit for this lesson.
Our Battle
class now manages all aspects of combat. In the next lesson, we are going to change the combat behavior, so its great to have this code isolated and with detailed tests… we can now change this behavior with confidence.