Lesson 4.10: Attack Initiative and Hit Logic

Now that we have the Battle class, we’re going to make the combat more interesting. Currently combat is very static… the player attacks and monster and then the monsters respond with their own attack. Attacks always hit the opponent and cause a random amount of damage. But that’s really all the variance in the combat engine.

We are going to make combat more dynamic by using combat initiative to determine who attacks first. Then we are going to have a chance for the attacker to hit or miss the opponent. And only deal damage if there is a valid hit. We are going to use new properties (Dexterity, Strength, and ArmorClass) on the LivingEntity to control these behaviors.

If these terms sound familiar, they are how combat is adjudicated in the Dungeons & Dragons roleplaying game. This type of attribute-based combat has been the main inspiration for many computer RPGs as well. So we will add these similar concepts to our combat engine.

Basic Attribute Changes

We are going to start by adding the properties that we need to the LivingEntity class. We will add them there because all creatures (players and monsters) need to have these same properties.

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 Dexterity { get; set; } = 10;

        public int Strength { get; set; } = 10;

        public int ArmorClass { get; set; } = 10;

        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;
            }
        }
    }
}

We define three new attributes as properties. All three default to 10 (which is considered the typical value for the attributes). Here is the purpose of each property:

  • Dexterity: represents the creature’s agility and speed. We use this attribute to see which creature attacks first and as a bonus to their defense.
  • Strength: represents the creature’s power. We use this attribute as a bonus to their attack to see if they hit their opponent.
  • ArmorClass: the base toughness to being hit. The attacker must hit over the defender’s armor class and bonus for the attack to succeed.

Now that the LivingEntity changes are done, we need to update the MonsterFactory to set these properties to specific values for each monster.

using SimpleRPG.Game.Engine.Models;
using SimpleRPG.Game.Engine.Services;
using System;

namespace SimpleRPG.Game.Engine.Factories
{
    internal static class MonsterFactory
    {
        private static readonly IDiceService _service = DiceService.Instance;

        public static Monster GetMonster(int monsterID)
        {
            switch (monsterID)
            {
                case 1:
                    Monster snake = new Monster
                    {
                        Name = "Snake",
                        ImageName = "/images/monsters/snake.png",
                        CurrentHitPoints = 4,
                        MaximumHitPoints = 4,
                        RewardExperiencePoints = 5,
                        Gold = 1,
                        Dexterity = 15,
                        Strength = 12,
                        ArmorClass = 10
                    };

                    snake.CurrentWeapon = ItemFactory.CreateGameItem(1501);
                    AddLootItem(snake, 9001, 25);
                    AddLootItem(snake, 9002, 75);
                    return snake;

                case 2:
                    Monster rat = new Monster
                    {
                        Name = "Rat",
                        ImageName = "/images/monsters/rat.png",
                        CurrentHitPoints = 5,
                        MaximumHitPoints = 5,
                        RewardExperiencePoints = 5,
                        Gold = 1,
                        Dexterity = 8,
                        Strength = 10,
                        ArmorClass = 10
                    };

                    rat.CurrentWeapon = ItemFactory.CreateGameItem(1502);
                    AddLootItem(rat, 9003, 25);
                    AddLootItem(rat, 9004, 75);
                    return rat;

                case 3:
                    Monster giantSpider = new Monster
                    {
                        Name ="Giant Spider",
                        ImageName = "/images/monsters/giant-spider.png",
                        CurrentHitPoints = 10,
                        MaximumHitPoints = 10,
                        RewardExperiencePoints = 10,
                        Gold = 3,
                        Dexterity = 12,
                        Strength = 15,
                        ArmorClass = 12
                    };

                    giantSpider.CurrentWeapon = ItemFactory.CreateGameItem(1503);
                    AddLootItem(giantSpider, 9005, 25);
                    AddLootItem(giantSpider, 9006, 75);
                    return giantSpider;

                default:
                    throw new ArgumentOutOfRangeException(nameof(monsterID));
            }
        }

        private static void AddLootItem(Monster monster, int itemID, int percentage)
        {
            if (_service.Roll("1d100").Value <= percentage)
            {
                monster.Inventory.AddItem(ItemFactory.CreateGameItem(itemID));
            }
        }
    }
}

As we can see, each monster got attributes that we feel represent their speed, strength, and physical toughness.

Update Battle to Manage Initiative

Now that we have these attributes in place, we are going to make several modifications to the Battle class.

using SimpleRPG.Game.Engine.Services;
using System;
using System.Collections.Generic;

namespace SimpleRPG.Game.Engine.Models
{
    public class Battle
    {
        public enum Combatant
        {
            Player,
            Opponent
        }

        private readonly DisplayMessageBroker _messageBroker = DisplayMessageBroker.Instance;
        private readonly Action _onPlayerKilled;
        private readonly Action _onOpponentKilled;
        private readonly IDiceService _diceService;

        public Battle(Action onPlayerKilled, Action onOpponenetKilled, IDiceService diceService)
        {
            _onPlayerKilled = onPlayerKilled;
            _onOpponentKilled = onOpponenetKilled;
            _diceService = diceService;
        }

        public void Attack(Player player, Monster opponent)
        {
            _ = player ?? throw new ArgumentNullException(nameof(player));
            _ = opponent ?? throw new ArgumentNullException(nameof(opponent));

            if (FirstAttacker(player, opponent) == Combatant.Player)
            {
                bool battleContinues = AttackOpponent(player, opponent);
                if (battleContinues)
                {
                    // if the monster is still alive, it attacks the player.
                    AttackPlayer(player, opponent);
                }
            }
            else
            {
                bool battleContinues = AttackPlayer(player, opponent);
                if (battleContinues)
                {
                    // if the player is still alive, attack the monster.
                    AttackOpponent(player, opponent);
                }
            }
        }

        private Combatant FirstAttacker(Player player, Monster opponent)
        {
            int playerBonus = AbilityCalculator.CalculateBonus(player.Dexterity);
            int oppBonus = AbilityCalculator.CalculateBonus(opponent.Dexterity);
            int playerInit = _diceService.Roll(20).Value + playerBonus;
            int oppInit = _diceService.Roll(20).Value + oppBonus;

            return (playerInit >= oppInit) ? Combatant.Player : Combatant.Opponent;
        }

        private bool AttackOpponent(Player player, Monster opponent)
        {
            if (player.CurrentWeapon == null)
            {
                _messageBroker.RaiseMessage(
                    new DisplayMessage("Combat Warning", "You must select a weapon, to attack."));
                return false;
            }

            // 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);
                return false;
            }

            return true;
        }

        private bool 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);
                return false;
            }

            return true;
        }

        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.
        }
    }
}

Let’s take a closer look at the individual changes:

  • Define the Combatant enum (line #9-13) with values for Player and Opponent, which we use to figure out which one attacks first.
  • Define a member variable to hold the IDiceService (line #18).
  • Update the constructor to accept an IDiceService and set it to the member variable (lines #20 & 24). We pass this service as a dependency into the constructor (following the Inversion of Control pattern that we covered in earlier lessons), so that we can replace it with a mock implementation in our tests. This allows us to test dynamic behavior with repeatable results.
  • Re-wrote the Attack method to see which creature attacks first, and then attack the other (if it is possible). For example, if the Player attacks first. Then we attack the monster. if it hasn’t died, then it attacks the player. We do the inverse if the Opponent is selected to attack first.
  • Implemented the FirstAttacker method (lines #52-60) to calculate which creature should attack first. This method uses the concept of Initiative as a randomly rolled number added to each creature’s Dexterity bonus. The Initiative for each creature is calculated and compared, whomever get the higher result attacks first.
  • Changed the AttackOpponent method signature (line #62) to return whether the combat should continue. Then ensure the method returns the correct value in a couple of different circumstances.
  • The AttackOpponent method also previously hard-coded that the player was always attacked after the monster, so we removed the AttackPlayer call and make that decision in the Attack method instead.
  • Change the AttackPlayer method signature (line #85) to also return whether the combat should continue. Then sure this method returns the appropriate flag when the player dies.

Along with this change to the Battle class, we introduced a new helper class as well – AbilityCalculator. This class is responsible for calculating bonuses based on the specified ability score… since all abilities follow this same formula, we wanted to put this code in a shared helper.

using System;

namespace SimpleRPG.Game.Engine.Models
{
    public static class AbilityCalculator
    {
        private const int _defaultAbilityScore = 10;

        public static int CalculateBonus(int score) =>
            (int)Math.Floor((score - _defaultAbilityScore) / 2.0);
    }
}

Ability bonuses usually go from -5 to +10 based on the creature’s ability score. An ability score of 10 is average, so that maps to a bonus of 0.

This was quite a bit of change and refactoring in the Battle class. Since we changed the behavior of how combat is processed by changing who might attack first, our tests will likely fail because they assumed the order we had: attack monster, then attack player. We will need to update our tests so that we can control who attacks first, and we need to add tests for when the monster actually attacks before the player (since those scenarios were not part of our existing testbed).

Update Attack Command To Check Hit Success

Now we want to update how hit detection and damage works in the Attack command. Our current, simplistic implementation always assumes a hit and deals random damage to the target. If it ever rolls a 0 damage (only in our testing code), then it simulates a miss. But that’s not how many game combat engines work. There is usually the concept of hitting, and missing the opponent altogether. And then, only if the hit is successful, does the attacker do damage. We want to build this into our attack logic.

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";
            string message;

            if (AttackSucceeded(actor, target))
            {
                int damage = _diceService.Roll(_damageDice).Value;
                target.TakeDamage(damage);

                message = $"{actorName} hit {targetName} for {damage} point{(damage > 1 ? "s" : "")}.";
            }
            else
            {
                message = $"{actorName} missed {targetName}.";
            }

            return new DisplayMessage(title, message);
        }

        private bool AttackSucceeded(LivingEntity actor, LivingEntity target)
        {
            int actorBonus = AbilityCalculator.CalculateBonus(actor.Strength);
            int actorAttack = _diceService.Roll(20).Value + actorBonus + actor.Level;
            int targetAC = target.ArmorClass + AbilityCalculator.CalculateBonus(target.Dexterity);

            return actorAttack >= targetAC;
        }
    }
}

First, let’s review the AttackSucceeded method (lines #56-63). We figure out the actor’s attack value by rolling a 20-sided die, adding its Strength bonus, and their level to it. As creatures go up in level, their innate ability to hit increases as well. Then, we calculate the total targetAC by adding the target’s base ArmorClass to their Dexterity bonus. The idea here is that higher agility and speed makes you them harder to hit.

With all of those calculations figured out, if the actor’s full attack value is greater than or equal to the target’s full AC value, then the attack succeeds.

Finally, we update the Execute method (lines #41-51) to call AttackSucceeded first to see if the actor hits the target. If the attack succeeds, we calculate the damage as before and deal that amount of damage to the target. If the hit attempt fails, then we just show a message saying the actor missed the target.

Again with this change in behavior we have to update our existing tests to follow our new expectations. And add new tests to cover areas around hit attempts missing. This is where using a mock IDiceService comes in very handy, because we can setup tests to always miss and other tests to always hit… that way we can test all of the various code paths.

Clean up GameSession

All that’s left now is some minor clean up to the GameSession view model.

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 Battle _battle;
        private readonly int _maximumMessagesCount = 100;
        private readonly Dictionary<string, Action> _userInputActions = new Dictionary<string, Action>();
        private readonly IDiceService _diceService = DiceService.Instance;

        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, IDiceService? diceService = null)
            : this()
        {
            _maximumMessagesCount = maxMessageCount;
            _diceService = diceService ?? DiceService.Instance;
        }

        public GameSession()
        {
            InitializeUserInputActions();
            _battle = new Battle(
                () => OnLocationChanged(_currentWorld.GetHomeLocation()),  // Return to Player's home
                () => GetMonsterAtCurrentLocation(),  // Gets another monster
                _diceService);

            CurrentPlayer = new Player
            {
                Name = "DarthPedro",
                CharacterClass = "Fighter",
                CurrentHitPoints = 10,
                MaximumHitPoints = 10,
                Gold = 1000,
                Level = 1,
                Dexterity = _diceService.Roll(6, 3).Value,
                Strength = _diceService.Roll(6, 3).Value,
                ArmorClass = 10
            };

            _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());
            }
        }
    }
}

Let’s delve into these changes as well:

  • Define a member variable to hold the IDiceService (line #16).
  • Update the constructor to accept an IDiceService and set it to the member variable (lines #32 & 36). For testing purposes similar to why we did it for the Battle class above.
  • Update the Battle class creation (lines #44-45) to pass in the _diceService to use in its random number generation.
  • Update the Player class creation (lines #55-57) to set those new properties. For Dexterity and Strength, we roll 3 six-sided dice (3-18) to randomly assign the player those ability values. For ArmorClass we just go with the default value of 10. In the future, we may introduce the concept of armor and shields. Then we would calculate the player’s armor class from their natural abilities and what they are wearing.

Showing Abilities in PlayerComponent

One last change we want to make is to show the player’s Strength and Dexterity values in our game screen, so that the player is aware of their abilities. Let’s update the PlayerComponent to do that.

<Table Borderless="true" Narrow="true">
    <TableHeader>
        <TableHeaderCell RowSpan="2">Player Data</TableHeaderCell>
    </TableHeader>
    <TableBody>
        <TableRow>
            <TableRowCell>Name:</TableRowCell>
            <TableRowCell>@Player.Name</TableRowCell>
        </TableRow>
        <TableRow>
            <TableRowCell>Class:</TableRowCell>
            <TableRowCell>@Player.CharacterClass</TableRowCell>
        </TableRow>
        <TableRow>
            <TableRowCell>Strength:</TableRowCell>
            <TableRowCell>@Player.Strength</TableRowCell>
        </TableRow>
        <TableRow>
            <TableRowCell>Dexterity:</TableRowCell>
            <TableRowCell>@Player.Dexterity</TableRowCell>
        </TableRow>
        <TableRow>
            <TableRowCell>Hit points:</TableRowCell>
            <TableRowCell>@Player.CurrentHitPoints</TableRowCell>
        </TableRow>
        <TableRow>
            <TableRowCell>Gold:</TableRowCell>
            <TableRowCell>@Player.Gold</TableRowCell>
        </TableRow>
        <TableRow>
            <TableRowCell>XP:</TableRowCell>
            <TableRowCell>@Player.ExperiencePoints</TableRowCell>
        </TableRow>
        <TableRow>
            <TableRowCell>Level:</TableRowCell>
            <TableRowCell>@Player.Level</TableRowCell>
        </TableRow>
    </TableBody>
</Table>

@code {
    [Parameter]
    public Player Player { get; set; } = new Player();
}

We add two new rows to the player table. The first row has the Strength label and shows the player’s Strength value. The second row shows their Dexterity. Since ArmorClass is just a default value at the moment, we aren’t going to add it to the displayed player information yet.

With these last changes the the view model and component, we are done upgrading our combat engine. We can now build and run the game again. Let’s chase down some monsters and attack them to see the difference in our combat.

Fig 1 – Combat system with initiative and hit attempts

You will notice that combat is now more difficult. Neither creature automatically hits the other, so there are some misses along with the hits. This makes our combat more realistic, and brings us closer to other roleplaying games that you have played. You will also notice that the monster attacks first sometimes too.

With our game engine enhancements complete for now, we are going to look at loading our game data from files in the next few lessons. We are going to focus on object serialization from the JSON format because it’s more used than XML these days. And it sets us up nicely for retrieving game data from web services that use JSON for their message payload.

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 )

Facebook photo

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

Connecting to %s