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.

 

 

 

Lesson Steps

 

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

 

 

Additional links for this project

Source code: https://github.com/ScottLilly/SOSCSRPG

Project plan: https://github.com/ScottLilly/SOSCSRPG/projects/1

Discord: https://discord.gg/AUYXYtH

Return to main page

8 thoughts on “Lesson 16.3: Complex attack initiative and hit success logic

  1. Hi Scott,

    Awesome tutorial!
    I found a little bug in the lesson or maybe with the previous one (not sure).

    I was fighting a snake, using the keyboard and not the mouse, then I died. So I respawn in the home house and when I press the space key (it’s the Z key in your code) to attack I lost hit points.

    After many test, I lost up to 6 hit points after respawning from a snake bite.

    Maybe when the player dies, the currentBattle must be set to null (the current monster maybe also). But then the AttackCurentMonster in the GameSession must verify that the current Battle is not null because it can be called when you play with the keyboard.

    Regards,
    Michel

    1. Thanks for letting me know, Michel.

      I added a bug to the tracking board, so I can look at it later (I’m currently packing everything, to move in a few weeks).

  2. Hi Scott,

    I went looking for a simple RPG tutorial in c# yesterday and stumbled on this one. I’ve been following along and making some small changes of my own on the way and just thought I’d mention my latest change that was inspired by your comment above “For the values, I’m using the traditional Dungeons and Dragons values of 3-18 (like adding up three six-sided dice rolls).”

    I’ve added a RollDice method to the RandomNumberGenerator class which allows me to specify the number of sides and the amount of rolls to make.

    I’m hoping to extend what you’ve done so far into something to simulate Pathfinder with my kids (going to take a lot of work) and with the amount of things that rely on different types of dice rolls, I’m sure this RollDice method will be worthwhile later on.

    For now it is only being used to set the player’s dexterity value on load but eventually I’ll use it for character creation, combat and all the other things that come up for dice in D&D.

            public static int RollDice(int sidesOnDice, int rollsOfDice)
            {
                if(sidesOnDice < 1)
                    throw new ArgumentException($"Invalid number of sides specified: '{sidesOnDice}'");
    
                if(rollsOfDice < 1)
                    throw new ArgumentException($"Invalid number of rolls specified: '{rollsOfDice}'");
    
                if (sidesOnDice == 1) 
                    return rollsOfDice;
    
                var total = 0;
    
                for (var i = 0; i < rollsOfDice; i++) 
                    total += NumberBetween(1, sidesOnDice);
    
                return total;
            }

    This is a nice change to the insurance software I usually deal with, thanks for all the effort you’ve put in creating and sharing it.

    Regards,
    Jason

      1. No problem. I added an Enum shortly after that comment and have started assigning dice to weapons rather than min/max damage as well as setting up a bonus damage property (though I haven’t started using that yet). I’m thinking ahead with this and planning to make other D&D features like combat/skill checks use the dice rolls later on.

        public enum Dice
        {
        D2 = 2,
        D3 = 3,
        D4 = 4,
        D6 = 6,
        D8 = 8,
        D10 = 10,
        D12 = 12,
        D20 = 20,
        D100 = 100
        }

        1. Thanks again!

          I’m trying to open up the project to other contributors. If you’re interested, and have a GitHub account, would you like to try adding your change and making a pull request? If not, that’s OK. I’m just trying this idea to (hopefully) increase the progress on the program faster than me being the only one making changes.

          If you want to try this, let me know your GitHub account and I’ll add you as a contributor.

  3. Hi, thanks for the lessons. I’m not sure if I missed something but i hit a couple of minor bugs.

    1. If i try and attack with the keyboard when theres no monster without chosing a weapon in that session(before leaving the start screen for example) I get an exception for the “AttackCurrentMonster” function in “GameSession” because _currentBattle is null.

    I used:
    if(_currentBattle != null)
    {
    //Attack
    }

    to get around it and everything seems fine.

    2. If i try and attack a monster without choosing a weapon i get an exception for “UseCurrentWeaponOn” in the “LivingEntity” class instead of the in-game “You must equip a weapon” message.

    I used:

    if(CurrentWeapon != null)
    {
    //attack
    }
    to get around this one and now i just get the message about equiping a weapon but it counts as a turn in battle with the monster attacking afterwards (which i actually quite like).

    Like I said not sure if I missed/messed up anything but thought you should know in case I didn’t.

Leave a Reply

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