Press "Enter" to skip to content

Lesson 16.3: Complex attack initiative and hit success logic

Now that we have our Battle class, let’s make the combat logic more interesting.

We’ll start by adding a Dexterity property to the LivingEntity class. We can use that to determine who attacks first and whether they hit or miss.

Then, we will create a new CombatService class and make a lot of small changes in several classes – eleven if I counted correctly.

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

On line 12, add the backing variable “_dexterity”, then add the “Dexterity” property on lines 31-39.

Notice that I’m using an expression-bodied lambda “=>” for the “get”. I changed the other “get”s in this class to use the same style (lines 23, 43, 53, 63, 73, and 93). This doesn’t do anything different from the previous way we wrote “get”s. But it’s what the cool kids are doing nowadays. So, we’ll do it too.

On lines 141 we’ll add a new “dexterity” parameter to the constructor and set the “Dexterity” property to that parameter value on line 145.

LivingEntity.cs
using System;
using System.Collections.Generic;
using Engine.Services;
namespace Engine.Models
{
    public abstract class LivingEntity : BaseNotificationClass
    {
        #region Properties
        private string _name;
        private int _dexterity;
        private int _currentHitPoints;
        private int _maximumHitPoints;
        private int _gold;
        private int _level;
        private GameItem _currentWeapon;
        private GameItem _currentConsumable;
        private Inventory _inventory;
        public string Name
        {
            get => _name;
            private set
            {
                _name = value;
                OnPropertyChanged();
            }
        }
        public int Dexterity
        {
            get => _dexterity;
            private set
            {
                _dexterity = value;
                OnPropertyChanged();
            }
        }
        public int CurrentHitPoints
        {
            get => _currentHitPoints;
            private set
            {
                _currentHitPoints = value;
                OnPropertyChanged();
            }
        }
        public int MaximumHitPoints
        {
            get => _maximumHitPoints;
            protected set
            {
                _maximumHitPoints = value;
                OnPropertyChanged();
            }
        }
        public int Gold
        {
            get => _gold;
            private set
            {
                _gold = value;
                OnPropertyChanged();
            }
        }
        public int Level
        {
            get => _level;
            protected set
            {
                _level = value;
                OnPropertyChanged();
            }
        }
        public Inventory Inventory
        {
            get => _inventory;
            private set
            {
                _inventory = value;
                OnPropertyChanged();
            }
        }
        public GameItem CurrentWeapon
        {
            get => _currentWeapon;
            set
            {
                if (_currentWeapon != null)
                {
                    _currentWeapon.Action.OnActionPerformed -= RaiseActionPerformedEvent;
                }
                _currentWeapon = value;
                if (_currentWeapon != null)
                {
                    _currentWeapon.Action.OnActionPerformed += RaiseActionPerformedEvent;
                }
                OnPropertyChanged();
            }
        }
        public GameItem CurrentConsumable
        {
            get => _currentConsumable;
            set
            {
                if(_currentConsumable != null)
                {
                    _currentConsumable.Action.OnActionPerformed -= RaiseActionPerformedEvent;
                }
                _currentConsumable = value;
                if (_currentConsumable != null)
                {
                    _currentConsumable.Action.OnActionPerformed += RaiseActionPerformedEvent;
                }
                OnPropertyChanged();
            }
        }
        public bool IsAlive => CurrentHitPoints > 0;
        public bool IsDead => !IsAlive;
        #endregion
        public event EventHandler<string> OnActionPerformed;
        public event EventHandler OnKilled;
        protected LivingEntity(string name, int maximumHitPoints, int currentHitPoints, 
                               int dexterity, int gold, int level = 1)
        {
            Name = name;
            Dexterity = dexterity;
            MaximumHitPoints = maximumHitPoints;
            CurrentHitPoints = currentHitPoints;
            Gold = gold;
            Level = level;
            Inventory = new Inventory();
        }
        public void UseCurrentWeaponOn(LivingEntity target)
        {
            CurrentWeapon.PerformAction(this, target);
        }
        public void UseCurrentConsumable()
        {
            CurrentConsumable.PerformAction(this, this);
            RemoveItemFromInventory(CurrentConsumable);
        }
        public void TakeDamage(int hitPointsOfDamage)
        {
            CurrentHitPoints -= hitPointsOfDamage;
            if(IsDead)
            {
                CurrentHitPoints = 0;
                RaiseOnKilledEvent();
            }
        }
        public void Heal(int hitPointsToHeal)
        {
            CurrentHitPoints += hitPointsToHeal;
            if(CurrentHitPoints > MaximumHitPoints)
            {
                CurrentHitPoints = MaximumHitPoints;
            }
        }
        public void CompletelyHeal()
        {
            CurrentHitPoints = MaximumHitPoints;
        }
        public void ReceiveGold(int amountOfGold)
        {
            Gold += amountOfGold;
        }
        public void SpendGold(int amountOfGold)
        {
            if(amountOfGold > Gold)
            {
                throw new ArgumentOutOfRangeException($"{Name} only has {Gold} gold, and cannot spend {amountOfGold} gold");
            }
            Gold -= amountOfGold;
        }
        public void AddItemToInventory(GameItem item)
        {
            Inventory = Inventory.AddItem(item);
        }
        public void RemoveItemFromInventory(GameItem item)
        {
            Inventory = Inventory.RemoveItem(item);
        }
        public void RemoveItemsFromInventory(IEnumerable<ItemQuantity> itemQuantities)
        {
            Inventory = Inventory.RemoveItems(itemQuantities);
        }
        #region Private functions
        private void RaiseOnKilledEvent()
        {
            OnKilled?.Invoke(this, new System.EventArgs());
        }
        private void RaiseActionPerformedEvent(object sender, string result)
        {
            OnActionPerformed?.Invoke(this, result);
        }
        #endregion
    }
}

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

Because LivingEntity is the base class for the Player and Monster classes, we need to modify their constructors to pass in (and get) dexterity values in their constructors.

Modify the Player constructor on lines 45-47 so it receives a “dexterity” parameter and passes it to its base class – Living Entity.

While we’re in the Player class, let’s change the “get”s on line 16 and 26 to use lambdas.

Player.cs
using System;
using System.Collections.ObjectModel;
using System.Linq;
namespace Engine.Models
{
    public class Player : LivingEntity
    {
        #region Properties
        private string _characterClass;
        private int _experiencePoints;
        public string CharacterClass
        {
            get => _characterClass;
            set
            {
                _characterClass = value;
                OnPropertyChanged();
            }
        }
        public int ExperiencePoints
        {
            get => _experiencePoints;
            private set
            {
                _experiencePoints = value;
                OnPropertyChanged();
                SetLevelAndMaximumHitPoints();
            }
        }
        public ObservableCollection<QuestStatus> Quests { get; }
        public ObservableCollection<Recipe> Recipes { get; }
        #endregion
        public event EventHandler OnLeveledUp;
        public Player(string name, string characterClass, int experiencePoints,
                      int maximumHitPoints, int currentHitPoints, int dexterity, int gold) : 
            base(name, maximumHitPoints, currentHitPoints, dexterity, gold)
        {
            CharacterClass = characterClass;
            ExperiencePoints = experiencePoints;
            Quests = new ObservableCollection<QuestStatus>();
            Recipes = new ObservableCollection<Recipe>();
        }
        public void AddExperience(int experiencePoints)
        {
            ExperiencePoints += experiencePoints;
        }
        public void LearnRecipe(Recipe recipe)
        {
            if(!Recipes.Any(r => r.ID == recipe.ID))
            {
                Recipes.Add(recipe);
            }
        }
        private void SetLevelAndMaximumHitPoints()
        {
            int originalLevel = Level;
            Level = (ExperiencePoints / 100) + 1;
            if (Level != originalLevel)
            {
                MaximumHitPoints = Level * 10;
                OnLeveledUp?.Invoke(this, System.EventArgs.Empty);
            }
        }
    }
}

Step 3: Modify \Engine\Models\Monster.cs

Change the constructor to accept a “dexterity” parameter and pass it to LivingEntity.

We also need to change the Clone function to pass in the dexterity parameter. This is on lines 39-41.

Monster.cs
using System.Collections.Generic;
using Engine.Factories;
namespace Engine.Models
{
    public class Monster : LivingEntity
    {
        private readonly List<ItemPercentage> _lootTable =
            new List<ItemPercentage>();
        public int ID { get; }
        public string ImageName { get; }
        public int RewardExperiencePoints { get; }
        public Monster(int id, string name, string imageName,
                       int maximumHitPoints, int dexterity,
                       GameItem currentWeapon,
                       int rewardExperiencePoints, int gold) :
            base(name, maximumHitPoints, maximumHitPoints, dexterity, gold)
        {
            ID = id;
            ImageName = imageName;
            CurrentWeapon = currentWeapon;
            RewardExperiencePoints = rewardExperiencePoints;
        }
        public void AddItemToLootTable(int id, int percentage)
        {
            // Remove the entry from the loot table,
            // if it already contains an entry with this ID
            _lootTable.RemoveAll(ip => ip.ID == id);
            _lootTable.Add(new ItemPercentage(id, percentage));
        }
        public Monster GetNewInstance()
        {
            // "Clone" this monster to a new Monster object
            Monster newMonster =
                new Monster(ID, Name, ImageName, MaximumHitPoints, Dexterity, 
                            CurrentWeapon, RewardExperiencePoints, Gold);
            foreach(ItemPercentage itemPercentage in _lootTable)
            {
                // Clone the loot table - even though we probably won't need it
                newMonster.AddItemToLootTable(itemPercentage.ID, itemPercentage.Percentage);
                // Populate the new monster's inventory, using the loot table
                if(RandomNumberGenerator.NumberBetween(1, 100) <= itemPercentage.Percentage)
                {
                    newMonster.AddItemToInventory(ItemFactory.CreateGameItem(itemPercentage.ID));
                }
            }
            return newMonster;
        }
    }
}

Step 4: Modify \Engine\GameData\Monsters.xml

We need a source for the monsters’ dexterity values, so we’ll modify the Monsters.xml file.

Instead of adding another attribute to the Monster node, I created a new child node “<Dexterity>”. We’re going to add a lot more to the Monster objects, and I don’t want a long list of attributes in the Monster node. So, we’ll switch to using child nodes for properties.

For the values, I’m using the traditional Dungeons and Dragons values of 3-18 (like adding up three six-sided dice rolls).

Monsters.xml
<?xml version="1.0" encoding="utf-8" ?>
<Monsters RootImagePath="/Images/Monsters/">
  <Monster ID="1" Name="Snake" MaximumHitPoints="4" WeaponID="1501" RewardXP="5" Gold="1" ImageName="Snake.png">
    <Dexterity>15</Dexterity>
    <LootItems>
      <LootItem ID="9001" Percentage="25"/>
      <LootItem ID="9002" Percentage="75"/>
    </LootItems>
  </Monster>
  <Monster ID="2" Name="Rat" MaximumHitPoints="5" WeaponID="1502" RewardXP="5" Gold="1" ImageName="Rat.png">
    <Dexterity>8</Dexterity>
    <LootItems>
      <LootItem ID="9003" Percentage="25"/>
      <LootItem ID="9004" Percentage="75"/>
    </LootItems>
  </Monster>
  <Monster ID="3" Name="Giant Spider" MaximumHitPoints="10" WeaponID="1503" RewardXP="10" Gold="3" ImageName="GiantSpider.png">
    <Dexterity>12</Dexterity>
    <LootItems>
      <LootItem ID="9005" Percentage="25"/>
      <LootItem ID="9006" Percentage="75"/>
    </LootItems>
  </Monster>
</Monsters>

Step 5: Modify \Engine\Factories\MonsterFactory.cs

Now that we have a Dexterity node in Monsters.xml, we need to modify the MonsterFactory to read it and pass the value to the constructor.

We need to add “using System;” to the using directives at the top, so we can convert the XML text value to an integer.

In the Monster constructor, add line 50 to read the Dexterity node’s value from the XML file, convert it to an integer, and pass it into the Monster constructor.

MonsterFactory.cs
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Xml;
using Engine.Models;
using Engine.Shared;
namespace Engine.Factories
{
    public static class MonsterFactory
    {
        private const string GAME_DATA_FILENAME = ".\\GameData\\Monsters.xml";
        private static readonly List<Monster> _baseMonsters = new List<Monster>();
        static MonsterFactory()
        {
            if(File.Exists(GAME_DATA_FILENAME))
            {
                XmlDocument data = new XmlDocument();
                data.LoadXml(File.ReadAllText(GAME_DATA_FILENAME));
                string rootImagePath =
                    data.SelectSingleNode("/Monsters")
                        .AttributeAsString("RootImagePath");
                LoadMonstersFromNodes(data.SelectNodes("/Monsters/Monster"), rootImagePath);
            }
            else
            {
                throw new FileNotFoundException($"Missing data file: {GAME_DATA_FILENAME}");
            }
        }
        private static void LoadMonstersFromNodes(XmlNodeList nodes, string rootImagePath)
        {
            if(nodes == null)
            {
                return;
            }
            foreach(XmlNode node in nodes)
            {
                Monster monster =
                    new Monster(node.AttributeAsInt("ID"),
                                node.AttributeAsString("Name"),
                                $".{rootImagePath}{node.AttributeAsString("ImageName")}",
                                node.AttributeAsInt("MaximumHitPoints"),
                                Convert.ToInt32(node.SelectSingleNode("./Dexterity").InnerText),
                                ItemFactory.CreateGameItem(node.AttributeAsInt("WeaponID")),
                                node.AttributeAsInt("RewardXP"),
                                node.AttributeAsInt("Gold"));
                XmlNodeList lootItemNodes = node.SelectNodes("./LootItems/LootItem");
                if(lootItemNodes != null)
                {
                    foreach(XmlNode lootItemNode in lootItemNodes)
                    {
                        monster.AddItemToLootTable(lootItemNode.AttributeAsInt("ID"),
                                                   lootItemNode.AttributeAsInt("Percentage"));
                    }
                }
                _baseMonsters.Add(monster);
            }
        }
        public static Monster GetMonster(int id)
        {
            return _baseMonsters.FirstOrDefault(m => m.ID == id)?.GetNewInstance();
        }
    }
}

Step 6: Modify \Engine\Models\Trader.cs

Since the Trader is a child of LivingEntity, we need to modify its constructor to pass in a “dexterity” value to LivingEntity’s constructor.

Because traders currently don’t fight, we’ll just pass in a hard-coded 18 for their dexterity.

Trader.cs
namespace Engine.Models
{
    public class Trader : LivingEntity
    {
        public int ID { get; }
        public Trader(int id, string name) : base(name, 9999, 9999, 18, 9999)
        {
            ID = id;
        }
    }
}

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

The last place we instantiate a LivingEntity object is in GameSession, when we instantiate the Player object.

For now, we’ll just add a random number for the Player’s Dexterity value. Eventually, we’ll create a Player creation screen that will handle the player’s attributes, name, etc.

GameSession.cs (lines 120-140)

        public GameSession()
        {
            int dexterity = RandomNumberGenerator.NumberBetween(3, 18);
            CurrentPlayer = new Player("Scott", "Fighter", 0, 10, 10, dexterity, 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);
        }

Step 8: Create \Engine\Services\CombatService.cs

This is where we’ll put the combat calculations.

I moved the Combatant enum into here, since we’ll return an enum value from the FirstAttacker function we’re moving into CombatService.

On lines 13-26, we have the new FirstAttacker() function. The formula is a modification I found to one on the internet. It scales, based on the difference in dexterity of the player and the monster. We also add a random value between -10 and +10 on line 20, to make the combat a little more interesting.

The same formula is used for the hit/miss function AttackSucceeded() – on lines 28-40. When we add more things that affect combat (skills, armor, etc.), we’ll modify this function.

CombatService.cs
using Engine.Models;
namespace Engine.Services
{
    public static class CombatService
    {
        public enum Combatant
        {
            Player,
            Opponent
        }
        public static Combatant FirstAttacker(Player player, Monster opponent)
        {
            // Formula is: ((Dex(player)^2 - Dex(monster)^2)/10) + Random(-10/10)
            // For dexterity values from 3 to 18, this should produce an offset of +/- 41.5
            int playerDexterity = player.Dexterity * player.Dexterity;
            int opponentDexterity = opponent.Dexterity * opponent.Dexterity;
            decimal dexterityOffset = (playerDexterity - opponentDexterity) / 10m;
            int randomOffset = RandomNumberGenerator.NumberBetween(-10, 10);
            decimal totalOffset = dexterityOffset + randomOffset;
            return RandomNumberGenerator.NumberBetween(0, 100) <= 50 + totalOffset 
                       ? Combatant.Player 
                       : Combatant.Opponent;
        }
        public static bool AttackSucceeded(LivingEntity attacker, LivingEntity target)
        {
            // Currently using the same formula as FirstAttacker initiative.
            // This will change as we include attack/defense skills,
            // armor, weapon bonuses, enchantments/curses, etc.
            int playerDexterity = attacker.Dexterity * attacker.Dexterity;
            int opponentDexterity = target.Dexterity * target.Dexterity;
            decimal dexterityOffset = (playerDexterity - opponentDexterity) / 10m;
            int randomOffset = RandomNumberGenerator.NumberBetween(-10, 10);
            decimal totalOffset = dexterityOffset + randomOffset;
            return RandomNumberGenerator.NumberBetween(0, 100) <= 50 + totalOffset;
        }
    }
}

Step 9: Modify \Engine\Models\Battle.cs

Now, we can finally modify the combat logic, since everything has a Dexterity value.

Delete the Combatant enum and the FirstAttacker function (lines 13-23), since we’re going to get those from CombatService.

Then, change line 27 in the constructor, to call CombatService.FirstAttacker()

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;
        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(CombatService.FirstAttacker(_player, _opponent) == CombatService.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 10: Modify \Engine\Actions\AttackWithWeapon.cs

This class used to generate a random damage amount and treat zero as a miss. Now, in the Execute() function we see if the attack succeeded on line 39. If so, we determine the amount of damage to do. If the attack fails, we display the “missed” message on line 49.

AttackWithWeapon.cs
using System;
using Engine.Models;
using Engine.Services;
namespace Engine.Actions
{
    public class AttackWithWeapon : BaseAction, IAction
    {
        private readonly int _maximumDamage;
        private readonly int _minimumDamage;
        public AttackWithWeapon(GameItem itemInUse, int minimumDamage, int maximumDamage) 
            : base(itemInUse)
        {
            if(itemInUse.Category != GameItem.ItemCategory.Weapon)
            {
                throw new ArgumentException($"{itemInUse.Name} is not a weapon");
            }
            if(minimumDamage < 0)
            {
                throw new ArgumentException("minimumDamage must be 0 or larger");
            }
            if(maximumDamage < minimumDamage)
            {
                throw new ArgumentException("maximumDamage must be >= minimumDamage");
            }
            _minimumDamage = minimumDamage;
            _maximumDamage = maximumDamage;
        }
        public void Execute(LivingEntity actor, LivingEntity target)
        {
            string actorName = (actor is Player) ? "You" : $"The {actor.Name.ToLower()}";
            string targetName = (target is Player) ? "you" : $"the {target.Name.ToLower()}";
            if(CombatService.AttackSucceeded(actor, target))
            {
                int damage = RandomNumberGenerator.NumberBetween(_minimumDamage, _maximumDamage);
                ReportResult($"{actorName} hit {targetName} for {damage} point{(damage > 1 ? "s" : "")}.");
                target.TakeDamage(damage);
            }
            else
            {
                ReportResult($"{actorName} missed {targetName}.");
            }
        }
    }
}

Step 11: Modify \Engine\Data\GameItems.xml

Now that we aren’t using zero damage to indicate a “miss”, we should go into GameItems.xml and set anything with a MinimumDamage of “0” to “1”. Otherwise, an attacker could succeed (based on the CombatSerice.AttackSucceeded function) but do zero damage. That doesn’t make sense to me.

GameItems.xml
<?xml version="1.0" encoding="utf-8" ?>
<GameItems>
  <Weapons>
    <Weapon ID="1001" Name="Pointy stick" Price="1" MinimumDamage="1" MaximumDamage="2"/>
    <Weapon ID="1002" Name="Rusty sword" Price="5" MinimumDamage="1" MaximumDamage="3"/>
    <Weapon ID="1501" Name="Snake fang" Price="0" MinimumDamage="1" MaximumDamage="2"/>
    <Weapon ID="1502" Name="Rat claw" Price="0" MinimumDamage="1" MaximumDamage="2"/>
    <Weapon ID="1503" Name="Spider fang" Price="0" MinimumDamage="1" MaximumDamage="4"/>
  </Weapons>
  <HealingItems>
    <HealingItem ID="2001" Name="Granola bar" Price="5" HitPointsToHeal="2"/>
  </HealingItems>
  <MiscellaneousItems>
    <MiscellaneousItem ID="3001" Name="Oats" Price="1"/>
    <MiscellaneousItem ID="3002" Name="Honey" Price="2"/>
    <MiscellaneousItem ID="3003" Name="Raisins" Price="2"/>
    <MiscellaneousItem ID="9001" Name="Snake fang" Price="1"/>
    <MiscellaneousItem ID="9002" Name="Snakeskin" Price="2"/>
    <MiscellaneousItem ID="9003" Name="Rat tail" Price="1"/>
    <MiscellaneousItem ID="9004" Name="Rat fur" Price="2"/>
    <MiscellaneousItem ID="9005" Name="Spider fang" Price="1"/>
    <MiscellaneousItem ID="9006" Name="Spider silk" Price="2"/>
  </MiscellaneousItems>
</GameItems>

Step 13: Modify \WPFUI\MainWindow.xaml

I added a line to show the Player’s Dexterity in the “Player’s Stats” section that starts on line 32.

Just add another “<RowDefinition Height=”Auto”/>” inside the “<Grid.RowDefinitions>” section from lines 34-42. Then, I added the Dexterity label and value to lines 54-55 and increased the “Grid.Row” value for all the rows that come after it.

MainWindow.xaml (lines 32-64)

        <!-- Player stats -->
        <Grid Grid.Row="1" Grid.Column="0" Background="Aquamarine">
            <Grid.RowDefinitions>
                <RowDefinition Height="Auto"/>
                <RowDefinition Height="Auto"/>
                <RowDefinition Height="Auto"/>
                <RowDefinition Height="Auto"/>
                <RowDefinition Height="Auto"/>
                <RowDefinition Height="Auto"/>
                <RowDefinition Height="Auto"/>
                <RowDefinition Height="Auto"/>
            </Grid.RowDefinitions>
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="Auto"/>
                <ColumnDefinition Width="Auto"/>
            </Grid.ColumnDefinitions>
            <Label Grid.Row="0" Grid.Column="0" Content="Name:"/>
            <Label Grid.Row="0" Grid.Column="1" Content="{Binding CurrentPlayer.Name}"/>
            <Label Grid.Row="1" Grid.Column="0" Content="Class:"/>
            <Label Grid.Row="1" Grid.Column="1" Content="{Binding CurrentPlayer.CharacterClass}"/>
            <Label Grid.Row="2" Grid.Column="0" Content="Dexterity:"/>
            <Label Grid.Row="2" Grid.Column="1" Content="{Binding CurrentPlayer.Dexterity}"/>
            <Label Grid.Row="3" Grid.Column="0" Content="Hit points:"/>
            <Label Grid.Row="3" Grid.Column="1" Content="{Binding CurrentPlayer.CurrentHitPoints}"/>
            <Label Grid.Row="4" Grid.Column="0" Content="Gold:"/>
            <Label Grid.Row="4" Grid.Column="1" Content="{Binding CurrentPlayer.Gold}"/>
            <Label Grid.Row="5" Grid.Column="0" Content="XP:"/>
            <Label Grid.Row="5" Grid.Column="1" Content="{Binding CurrentPlayer.ExperiencePoints}"/>
            <Label Grid.Row="6" Grid.Column="0" Content="Level:"/>
            <Label Grid.Row="6" Grid.Column="1" Content="{Binding CurrentPlayer.Level}"/>
        </Grid>

Step 14: Test the game

Finally, we can play the game to test it.

I added a TestCombatService function to the Test project. But, because we use random numbers in our combat functions, we can’t really use automated unit tests.

With the random numbers, the functions are “non-deterministic”. They don’t give always the same output, when passed in the same input parameters. You can only really do unit tests on “deterministic” functions – ones that always return the same output for the same inputs.

But I added Test_FirstAttacker so I could step through the code with the debugger, without needing to create a complete set of game objects.

When you play the game, notice what the Player’s Dexterity is. Restart the game a few times and see how well the Player does in combat when they have a high dexterity, and they have a low dexterity. You’ll probably notice a difference.

TestCombatService.cs
using Engine.Models;
using Engine.Services;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace TestEngine.Services
{
    [TestClass]
    public class TestCombatService
    {
        [TestMethod]
        public void Test_FirstAttacker()
        {
            // Player and monster with dexterity 12
            Player player = new Player("", "", 0, 0, 0, 18, 0);
            Monster monster = new Monster(0, "", "", 0, 12, null, 0, 0);
            CombatService.Combatant result = CombatService.FirstAttacker(player, monster);
        }
    }
}

NEXT LESSON: Lesson 17.1: Saving and loading game state

PREVIOUS LESSON: Lesson 16.2: Creating the Battle class

2 Comments

  1. Ted Bunny
    Ted Bunny 2022-01-26

    If anyone doing this tutorial dreads making a bunch of XML changes by rote, check out XiMpLe. It’s a free tool that allows you to edit XML in a table/grid format, so adding new attributes is as simple as adding a column. Compare this to raw text editing, where you’d have to manually do it for every node.

    • SOSCSRPG
      SOSCSRPG 2022-01-26

      Thank you for sharing that!

      One of the features I want to add fairly soon (we probably need to make sure the model classes are fairly stable) is a WPF program to make it easier to edit the game’s data files (locations, items, traders, etc.)

Leave a Reply

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