Lesson 4.9: Enhanced Combat with Battle Class

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 in lesson 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 the GameSession. At this point, it only checks the player and opponent parameters and then calls the AttackOpponent 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 the GameSession.CurrentPlayer and CurrentMonster. Then, we replaced all of the AddDisplayMessage calls with the _messageBroker.RaiseMessage call.
  • In line #71, we invoke the _onPlayerKilled action/delegate to let the GameSession move the player back home. We use this delegate callback because we cannot and don’t want to control the current game location from this Battle class.
  • In line #93, we invoke the _onOpponentKilled action/delegate to let the GameSession 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 the GameSession constructor (lines #39-41). In this code, we can see:
    • the first action (onPlayerKilled) maps to a call to OnLocationChanged method to the home location.
    • the second action (onOpponentKilled) maps to the GetMonsterAtCurrentLocation method, which gets a fresh monster found at this location.
  • The AttackCurrentMonster method now checks that we have a CurrentMonster at this location, sets the CurrentPlayer.CurrentWeapon to the weapon passed to this method, and calls the _battle.Attack method with the CurrentPlayer and the CurrentMonster.

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.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s