Press "Enter" to skip to content

Lesson 16.2: Creating the Battle class

Now that we have a decoupled message broker, we can move most of our existing combat logic into the new Battle class.

Step 1: Create \Engine\EventArgs\CombatVictoryEventArgs.cs

This is a simple EventArgs class to let our Battle class notify the GameSession class that the player defeated their opponent.

It doesn’t have any payload (additional properties), because we don’t need to pass any additional information with the event – only that the player won the battle.

CombatVictoryEventArgs.cs
namespace Engine.EventArgs
{
    public class CombatVictoryEventArgs : System.EventArgs
    {
    }
}

Step 2: Modify \Engine\Models\LivingEntity.cs

Previously, we had an IsDead property on line 122. I added an IsAlive property, to make a check in our Battle class sound positive – _opponent.IsAlive, instead of !_opponent.IsDead.

I also changed the code for IsDead to “!IsAlive”, so we don’t make a mistake by have one property “CurrentHitPoints <= 0” and the other “CurrentHitPoints > 0”. That’s a good way to accidentally forget an equal sign on one of the properties.

LivingEntity.cs (lines 122-123)

        public bool IsAlive => CurrentHitPoints > 0;
        public bool IsDead => !IsAlive;

Step 3: Create \Engine\Models\Battle.cs

This is where we’ll put the combat logic.

On line 7, notice that this class implements the IDisposable interface. This means the class must have a Dispose() function. We’re going to use Dispose() to unsubscribe to eventhandlers. If we didn’t do this, we’d get some strange behavior of previous battles staying in memory and potentially interacting with the other classes.

On lines 10 and 11, we store the player and opponent values that are passed into Battle’s constructor.

There’s an enum “Combatant” on line 13. We’ll use this to make the code a little easier to read when we add the logic to decide who attacks first in a battle – the player or the monster.

Lines 19-22 is an expression-bodied function, a little function we can call to get a random number to determine who attacks first in a battle.

Line 24 is the event GameSession will subscribe to, to know if the player won a battle, and if GameSession needs to instantiate new Monster and Battle objects.

Lines 26-42 are the Battle constructor.

It accepts the Player object and Monster and stores them in the private class-level variables for later use.

Then, we subscribe to the combat action events for the Player and Monster and the Monster’s OnKilled event. We don’t subscribe to the Player’s OnKilled event here. We’ll leave that in the GameSession class, in case we add other non-Battle ways for the Player to die – like being cursed or poisoned and receiving damage every turn.

We raise some UI messages, to display what the player is fighting, on lines 35 and 36.

Finally, on lines 38-41, we call the FirstAttacker function. If the Monster is selected, we call the AttackPlayer() function, where the Monster attacks the Player.

On lines 44-58, we have the Player’s attack function. It’s mostly cut-and-pasted from the old GameSession class. But we removed the code to check if the monster died. That will be handled by our eventhandler for the Monster’s OnKilled event.

The Dispose() function on lines 60-65 unsubscribes to the events that were subscribed to in the constructor. Without having this (and calling it), old Battle objects might never be released from memory. The garbage collector will see that something is still subscribed to the events, so it will think the object needs to be kept in memory.

Lines 67-85 contains the logic for a Player victory – giving the Player experience, gold, and the Monster’s loot items. It’s mostly cut-and-pasted from the old GameSession code.

After giving the victory rewards, we raise an OnCombatVictory event on line 84. GameSession will subscribe to this event and use it to know it needs to instantiate new Monster and Battle objects for the next fight.

Lines 87-90 hold the Player’s attack code, which just uses their current weapon on the Monster.

Lines 92- 95 has OnCombatActionPerformed, which was subscribed to by the Player’s and Monster’s OnActionPerformed events in the constructor. All it does is send the attack message to the MessageBroker.

Battle.cs
using System;
using Engine.EventArgs;
using Engine.Services;
namespace Engine.Models
{
    public class Battle : IDisposable
    {
        private readonly MessageBroker _messageBroker = MessageBroker.GetInstance();
        private readonly Player _player;
        private readonly Monster _opponent;
        private enum Combatant
        {
            Player,
            Opponent
        }
        private static Combatant FirstAttacker =>
            RandomNumberGenerator.NumberBetween(1, 2) == 1 ?
                Combatant.Player :
                Combatant.Opponent;
        public event EventHandler<CombatVictoryEventArgs> OnCombatVictory;
        public Battle(Player player, Monster opponent)
        {
            _player = player;
            _opponent = opponent;
            _player.OnActionPerformed += OnCombatantActionPerformed;
            _opponent.OnActionPerformed += OnCombatantActionPerformed;
            _opponent.OnKilled += OnOpponentKilled;
            _messageBroker.RaiseMessage("");
            _messageBroker.RaiseMessage($"You see a {_opponent.Name} here!");
            if(FirstAttacker == Combatant.Opponent)
            {
                AttackPlayer();
            }
        }
        public void AttackOpponent()
        {
            if(_player.CurrentWeapon == null)
            {
                _messageBroker.RaiseMessage("You must select a weapon, to attack.");
                return;
            }
            _player.UseCurrentWeaponOn(_opponent);
            if(_opponent.IsAlive)
            {
                AttackPlayer();
            }
        }
        public void Dispose()
        {
            _player.OnActionPerformed -= OnCombatantActionPerformed;
            _opponent.OnActionPerformed -= OnCombatantActionPerformed;
            _opponent.OnKilled -= OnOpponentKilled;
        }
        private void OnOpponentKilled(object sender, System.EventArgs e)
        {
            _messageBroker.RaiseMessage("");
            _messageBroker.RaiseMessage($"You defeated the {_opponent.Name}!");
            _messageBroker.RaiseMessage($"You receive {_opponent.RewardExperiencePoints} experience points.");
            _player.AddExperience(_opponent.RewardExperiencePoints);
            _messageBroker.RaiseMessage($"You receive {_opponent.Gold} gold.");
            _player.ReceiveGold(_opponent.Gold);
            foreach(GameItem gameItem in _opponent.Inventory.Items)
            {
                _messageBroker.RaiseMessage($"You receive one {gameItem.Name}.");
                _player.AddItemToInventory(gameItem);
            }
            OnCombatVictory?.Invoke(this, new CombatVictoryEventArgs());
        }
        private void AttackPlayer()
        {
            _opponent.UseCurrentWeaponOn(_player);
        }
        private void OnCombatantActionPerformed(object sender, string result)
        {
            _messageBroker.RaiseMessage(result);
        }
    }
}

Step 4: Modify \Engine\ViewModels\GameSession.cs

Now we can delete most of the combat logic from GameSession.

Because we’re adding and deleting lines, double-check the property and function names when you make the changes below. Compare your code with the source code listed below when you make your changes.

On line 12, add “_currentBattle”, to store the new Battle object.

On lines 28 and 37, remove the _currentPlayer.OnActionPerformed subscription and unsubscription.

I also renamed the “OnCurrentPlayerKilled” function to “OnPlayerKilled”. This is a single-player game, and “current player” seems redundant.

On line 59, in the CurrentLocation setter, I changed “GetMonsterAtLocation();” to “CurrentMonster = CurrentLocation.GetMonster();”. The GetMonsterAtLocation function was only one line, and only used in two places. So, I decided to eliminate it and just put the one line of code in the places where the function was called.

In the CurrentMonster setter, lines 69-85, I removed the event subscriptions to the Monster’s events and replaced them with subscriptions to the Battle’s events. Notice the call to “_currentBattle.Dispose();” on line 73. This is how we clean up the previous Battle object, before we create the new Battle object.

On line 244, delete the GetMonsterAtLocation() function, because we moved that single line of code into the two locations that used to call this function.

On line 249, replace the old logic for AttackCurrentMonster() with a single line that calls “_currentBattle.AttackOpponent();”, since the combat logic is in the Battle class now.

Delete the OnCurrentPlayerPerformedAction() function on line 310 and OnCurrentMonsterPerformedAction() function on line 315. That logic is in the Battle class now.

Rename “OnCurrentPlayerKilled()”, on line 320, to “OnPlayerKilled()”.

On line 329, change the OnCurrentMonsterKilled() function to just set the CurrentMonster property to a new Monster from the CurrentLocation. All the rewards and looting logic in in the Battle class now.

GameSession.cs
using System.Linq;
using Engine.Factories;
using Engine.Models;
using Engine.Services;
namespace Engine.ViewModels
{
    public class GameSession : BaseNotificationClass
    {
        private readonly MessageBroker _messageBroker = MessageBroker.GetInstance();
        private Battle _currentBattle;
        #region Properties
        private Player _currentPlayer;
        private Location _currentLocation;
        private Monster _currentMonster;
        private Trader _currentTrader;
        public World CurrentWorld { get; }
        public Player CurrentPlayer
        {
            get => _currentPlayer;
            set
            {
                if(_currentPlayer != null)
                {
                    _currentPlayer.OnLeveledUp -= OnCurrentPlayerLeveledUp;
                    _currentPlayer.OnKilled -= OnPlayerKilled;
                }
                _currentPlayer = value;
                if(_currentPlayer != null)
                {
                    _currentPlayer.OnLeveledUp += OnCurrentPlayerLeveledUp;
                    _currentPlayer.OnKilled += OnPlayerKilled;
                }
            }
        }
        public Location CurrentLocation
        {
            get => _currentLocation;
            set
            {
                _currentLocation = value;
                OnPropertyChanged();
                OnPropertyChanged(nameof(HasLocationToNorth));
                OnPropertyChanged(nameof(HasLocationToEast));
                OnPropertyChanged(nameof(HasLocationToWest));
                OnPropertyChanged(nameof(HasLocationToSouth));
                CompleteQuestsAtLocation();
                GivePlayerQuestsAtLocation();
                CurrentMonster = CurrentLocation.GetMonster();
                CurrentTrader = CurrentLocation.TraderHere;
            }
        }
        public Monster CurrentMonster
        {
            get => _currentMonster;
            set
            {
                if(_currentBattle != null)
                {
                    _currentBattle.OnCombatVictory -= OnCurrentMonsterKilled;
                    _currentBattle.Dispose();
                }
                _currentMonster = value;
                if(_currentMonster != null)
                {
                    _currentBattle = new Battle(CurrentPlayer, CurrentMonster);
                    _currentBattle.OnCombatVictory += OnCurrentMonsterKilled;
                }
                OnPropertyChanged();
                OnPropertyChanged(nameof(HasMonster));
            }
        }
        public Trader CurrentTrader
        {
            get => _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.Inventory.Weapons.Any())
            {
                CurrentPlayer.AddItemToInventory(ItemFactory.CreateGameItem(1001));
            }
            CurrentPlayer.AddItemToInventory(ItemFactory.CreateGameItem(2001));
            CurrentPlayer.LearnRecipe(RecipeFactory.RecipeByID(1));
            CurrentPlayer.AddItemToInventory(ItemFactory.CreateGameItem(3001));
            CurrentPlayer.AddItemToInventory(ItemFactory.CreateGameItem(3002));
            CurrentPlayer.AddItemToInventory(ItemFactory.CreateGameItem(3003));
            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.Inventory.HasAllTheseItems(quest.ItemsToComplete))
                    {
                        CurrentPlayer.RemoveItemsFromInventory(quest.ItemsToComplete);
                        _messageBroker.RaiseMessage("");
                        _messageBroker.RaiseMessage($"You completed the '{quest.Name}' quest");
                        // Give the player the quest rewards
                        _messageBroker.RaiseMessage($"You receive {quest.RewardExperiencePoints} experience points");
                        CurrentPlayer.AddExperience(quest.RewardExperiencePoints);
                        _messageBroker.RaiseMessage($"You receive {quest.RewardGold} gold");
                        CurrentPlayer.ReceiveGold(quest.RewardGold);
                        foreach(ItemQuantity itemQuantity in quest.RewardItems)
                        {
                            GameItem rewardItem = ItemFactory.CreateGameItem(itemQuantity.ItemID);
                            _messageBroker.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));
                    _messageBroker.RaiseMessage("");
                    _messageBroker.RaiseMessage($"You receive the '{quest.Name}' quest");
                    _messageBroker.RaiseMessage(quest.Description);
                    _messageBroker.RaiseMessage("Return with:");
                    foreach(ItemQuantity itemQuantity in quest.ItemsToComplete)
                    {
                        _messageBroker
                            .RaiseMessage($"   {itemQuantity.Quantity} {ItemFactory.CreateGameItem(itemQuantity.ItemID).Name}");
                    }
                    _messageBroker.RaiseMessage("And you will receive:");
                    _messageBroker.RaiseMessage($"   {quest.RewardExperiencePoints} experience points");
                    _messageBroker.RaiseMessage($"   {quest.RewardGold} gold");
                    foreach(ItemQuantity itemQuantity in quest.RewardItems)
                    {
                        _messageBroker
                            .RaiseMessage($"   {itemQuantity.Quantity} {ItemFactory.CreateGameItem(itemQuantity.ItemID).Name}");
                    }
                }
            }
        }
        public void AttackCurrentMonster()
        {
            _currentBattle.AttackOpponent();
        }
        public void UseCurrentConsumable()
        {
            if(CurrentPlayer.CurrentConsumable != null)
            {
                CurrentPlayer.UseCurrentConsumable();
            }
        }
        public void CraftItemUsing(Recipe recipe)
        {
            if(CurrentPlayer.Inventory.HasAllTheseItems(recipe.Ingredients))
            {
                CurrentPlayer.RemoveItemsFromInventory(recipe.Ingredients);
                foreach(ItemQuantity itemQuantity in recipe.OutputItems)
                {
                    for(int i = 0; i < itemQuantity.Quantity; i++)
                    {
                        GameItem outputItem = ItemFactory.CreateGameItem(itemQuantity.ItemID);
                        CurrentPlayer.AddItemToInventory(outputItem);
                        _messageBroker.RaiseMessage($"You craft 1 {outputItem.Name}");
                    }
                }
            }
            else
            {
                _messageBroker.RaiseMessage("You do not have the required ingredients:");
                foreach(ItemQuantity itemQuantity in recipe.Ingredients)
                {
                    _messageBroker
                        .RaiseMessage($"  {itemQuantity.Quantity} {ItemFactory.ItemName(itemQuantity.ItemID)}");
                }
            }
        }
        private void OnPlayerKilled(object sender, System.EventArgs e)
        {
            _messageBroker.RaiseMessage("");
            _messageBroker.RaiseMessage("You have been killed.");
            CurrentLocation = CurrentWorld.LocationAt(0, -1);
            CurrentPlayer.CompletelyHeal();
        }
        private void OnCurrentMonsterKilled(object sender, System.EventArgs eventArgs)
        {
            // Get another monster to fight
            CurrentMonster = CurrentLocation.GetMonster();
        }
        private void OnCurrentPlayerLeveledUp(object sender, System.EventArgs eventArgs)
        {
            _messageBroker.RaiseMessage($"You are now level {CurrentPlayer.Level}!");
        }
    }
}

Step 5: Test the game

We make some significant changes, so let’s make sure the game still works.

If you want to see why we added the Dispose function, you can temporarily comment it out on line 73. The first monster we fight looks OK. But when we fight the second (and subsequent) monsters, you’ll see we start to get extra combat messages.

NEXT LESSON: Lesson 16.3: Complex attack initiative and hit success logic

PREVIOUS LESSON: Lesson 16.1: Adding centralized messaging

    Leave a Reply

    Your email address will not be published. Required fields are marked *