Lesson 07.4: Monster Combat

Now that we can send messages from the ViewModel to the View, we can add the code for the player to fight the monsters.

 

 

LESSON STEPS

 

Step 1: Edit Engine\Models\Player.cs

We want to give the player the ability to select the weapon to fight with. So, we’re going to display a combobox (dropdown box) on the screen, with all their weapons.

To do this, we’re going to create a new property in the Player class. This is the Weapons property (lines 80 and 81).

Because this property is a subset of the Inventory property, we won’t use a getter and setter. It will just be a LINQ query of all objects in the Inventory property whose datatype is Weapon.

We need to add the ToList() at the end of the LINQ Where() because LINQ queries are not actually run until they have to do something with the results. This is called “deferred execution”.

 

Because this property doesn’t have a “set”, we need to manually raise the property changed event for it, whenever its value changes. We’ll do that in the AddItemToInventory (lines 93 through 98).

We could add GameItem objects directly to the Inventory property. However, using this function gives us some extra capabilities.

For now, the function will only add the item to the player’s inventory and raise a property changed event for the Weapons property. In the future, we will could use this to limit the player’s inventory – if we wanted to add weight to the GameItems, and limit the amount of weight a player can carry.

 

Player.cs

using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;

namespace Engine.Models
{
    public class Player : BaseNotificationClass
    {
        #region Properties

        private string _name;
        private string _characterClass;
        private int _hitPoints;
        private int _experiencePoints;
        private int _level;
        private int _gold;

        public string Name
        {
            get { return _name; }
            set
            {
                _name = value;
                OnPropertyChanged(nameof(Name));
            }
        }

        public string CharacterClass
        {
            get { return _characterClass; }
            set
            {
                _characterClass = value;
                OnPropertyChanged(nameof(CharacterClass));
            }
        }

        public int HitPoints
        {
            get { return _hitPoints; }
            set
            {
                _hitPoints = value;
                OnPropertyChanged(nameof(HitPoints));
            }
        }

        public int ExperiencePoints
        {
            get { return _experiencePoints; }
            set
            {
                _experiencePoints = value;
                OnPropertyChanged(nameof(ExperiencePoints));
            }
        }

        public int Level
        {
            get { return _level; }
            set
            {
                _level = value;
                OnPropertyChanged(nameof(Level));
            }
        }

        public int Gold
        {
            get { return _gold; }
            set
            {
                _gold = value;
                OnPropertyChanged(nameof(Gold));
            }
        }

        public ObservableCollection<GameItem> Inventory { get; set; }

        public List<GameItem> Weapons => 
            Inventory.Where(i => i is Weapon).ToList();

        public ObservableCollection<QuestStatus> Quests { get; set; }

        #endregion

        public Player()
        {
            Inventory = new ObservableCollection<GameItem>();
            Quests = new ObservableCollection<QuestStatus>();
        }

        public void AddItemToInventory(GameItem item)
        {
            Inventory.Add(item);

            OnPropertyChanged(nameof(Weapons));
        }
    }
}

Step 2: Edit Engine\Factories\ItemFactory.cs

Currently, the ItemFactory function CreateGameItem() returns an object with a GameItem datatype. That’s the base datatype for all game objects. In order to know which items in the player’s inventory are weapons, we need to make a change to CreateGameItem().

When CreateGameItem creates a new object, it uses the Clone function on the “standardItem” object. However, since “standardObject” is declared as a GameItem, this will use the Clone function in the GameItem class.

This returns a GameItem object, that only has its ItemTypeID, Name, and Price properties set (the only parameters used in the Clone function). When we create a Weapon object, we need to use the Clone function from the Weapon class.

To do this, we’ll add the code on lines 31 through 34.

If the standardItem’s datatype is Weapon, we will cast it as a Weapon object (the “(standardItem as Weapon)” part of line 33), and then call its Clone function. This will use the Clone function from the Weapon class.

 

ItemFactory.cs

using System.Collections.Generic;
using System.Linq;
using Engine.Models;

namespace Engine.Factories
{
    public static class ItemFactory
    {
        private static List<GameItem> _standardGameItems;

        static ItemFactory()
        {
            _standardGameItems = new List<GameItem>();

            _standardGameItems.Add(new Weapon(1001, "Pointy Stick", 1, 1, 2));
            _standardGameItems.Add(new Weapon(1002, "Rusty Sword", 5, 1, 3));
            _standardGameItems.Add(new GameItem(9001, "Snake fang", 1));
            _standardGameItems.Add(new GameItem(9002, "Snakeskin", 2));
            _standardGameItems.Add(new GameItem(9003, "Rat tail", 1));
            _standardGameItems.Add(new GameItem(9004, "Rat fur", 2));
            _standardGameItems.Add(new GameItem(9005, "Spider fang", 1));
            _standardGameItems.Add(new GameItem(9006, "Spider silk", 2));
        }

        public static GameItem CreateGameItem(int itemTypeID)
        {
            GameItem standardItem = _standardGameItems.FirstOrDefault(item => item.ItemTypeID == itemTypeID);

            if (standardItem != null)
            {
                if (standardItem is Weapon)
                {
                    return (standardItem as Weapon).Clone();
                }

                return standardItem.Clone();
            }

            return null;
        }
    }
}

 

Step 3: Edit Engine\Models\Monster.cs and Engine\Factories\MonsterFactory.cs

Now that the player can have a weapon, it’s only fair to let the monsters do some damage.

In the future, we can add the ability for monsters to use weapons. But, for now, because all our monsters are animals, they will only do damage by biting.

We’ll add two new properties to the Monster class: MinimumDamage and MaximumDamage (both integer properties) – on lines 21 and 22.

To populate these properties, we’ll add two new parameters to the Monster constructor (line 31), and set the properties with the passed-in parameter values (lines 38 and 39).

Now we need to pass values for these parameters when we instantiate a Monster object in the MonsterFactory.

Change lines 14 and 23, to pass in 1 as the minimumDamage and 2 as the maximumDamage. Because spiders are much more powerful, I changed line 32 to pass in 1 as the minimumDamage, and 4 as the maximumDamage.

 

MonsterFactory.cs

using System;
using Engine.Models;

namespace Engine.Factories
{
    public static class MonsterFactory
    {
        public static Monster GetMonster(int monsterID)
        {
            switch (monsterID)
            {
                case 1:
                    Monster snake = 
                        new Monster("Snake", "Snake.png", 4, 4, 1, 2, 5, 1);

                    AddLootItem(snake, 9001, 25);
                    AddLootItem(snake, 9002, 75);

                    return snake;

                case 2:
                    Monster rat = 
                        new Monster("Rat", "Rat.png", 5, 5, 1, 2, 5, 1);

                    AddLootItem(rat, 9003, 25);
                    AddLootItem(rat, 9004, 75);

                    return rat;

                case 3:
                    Monster giantSpider = 
                        new Monster("Giant Spider", "GiantSpider.png", 10, 10, 1, 4, 10, 3);

                    AddLootItem(giantSpider, 9005, 25);
                    AddLootItem(giantSpider, 9006, 75);

                    return giantSpider;

                default:
                    throw new ArgumentException(string.Format("MonsterType '{0}' does not exist", monsterID));
            }
        }

        private static void AddLootItem(Monster monster, int itemID, int percentage)
        {
            if (RandomNumberGenerator.NumberBetween(1, 100) <= percentage)
            {
                monster.Inventory.Add(new ItemQuantity(itemID, 1));
            }
        }
    }
}

Monster.cs

using System.Collections.ObjectModel;

namespace Engine.Models
{
    public class Monster : BaseNotificationClass
    {
        private int _hitPoints;

        public string Name { get; private set; }
        public string ImageName { get; set; }
        public int MaximumHitPoints { get; private set; }
        public int HitPoints
        {
            get { return _hitPoints; }
            set
            {
                _hitPoints = value;
                OnPropertyChanged(nameof(HitPoints));
            }
        }
        public int MinimumDamage { get; set; }
        public int MaximumDamage { get; set; }

        public int RewardExperiencePoints { get; private set; }
        public int RewardGold { get; private set; }

        public ObservableCollection<ItemQuantity> Inventory { get; set; }

        public Monster(string name, string imageName,
                       int maximumHitPoints, int hitPoints,
                       int minimumDamage, int maxmumDamage,
                       int rewardExperiencePoints, int rewardGold)
        {
            Name = name;
            ImageName = string.Format("/Engine;component/Images/Monsters/{0}", imageName);
            MaximumHitPoints = maximumHitPoints;
            HitPoints = hitPoints;
            MinimumDamage = minimumDamage;
            MaximumDamage = maxmumDamage;
            RewardExperiencePoints = rewardExperiencePoints;
            RewardGold = rewardGold;

            Inventory = new ObservableCollection<ItemQuantity>();
        }
    }
}

 

Step 4: Edit WPFUI\MainWindow.xaml and MainWindow.xaml.cs

We will display the combat controls in MainWindow.xaml, using the code in lines 212 through 237.

On line 228 is a new ComboBox control. This displays a selectable dropdown box. We’ll populate it with the player’s Weapons (the ItemSource), and bind the GameSession’s “CurrentWeapon” property to the SelectedItem of the ComboBox. This way, if the player changes the item in the ComboBox, it will update the CurrentWeapon property – and we will use the correct weapon during combat.

On 234-236 is the button that will call the combat function “OnClick_AttackMonster”.

 

In MainWindow.xaml.cs, the new function to attack the monster (OnClick_AttackMonster) is on lines 46 through 49. It will call the AttackCurrentMonster function we will create next in the GameSession class.

 

MainWindow.xaml

<Window x:Class="WPFUI.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:viewModels="clr-namespace:Engine.ViewModels;assembly=Engine"
        d:DataContext="{d:DesignInstance viewModels:GameSession}"
        mc:Ignorable="d"
        FontSize="11pt"
        Title="Scott's Awesome Game" Height="768" Width="1024">

    <Window.Resources>
        <BooleanToVisibilityConverter x:Key="BooleanToVisibility" />
    </Window.Resources>

    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="*"/>
            <RowDefinition Height="225"/>
        </Grid.RowDefinitions>

        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="250"/>
            <ColumnDefinition Width="*"/>
        </Grid.ColumnDefinitions>

        <!-- Menu -->
        <Label Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="2" Content="Menu" Background="AliceBlue"/>

        <!-- 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"/>
            </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="Hit points:"/>
            <Label Grid.Row="2" Grid.Column="1" Content="{Binding CurrentPlayer.HitPoints}"/>
            <Label Grid.Row="3" Grid.Column="0" Content="Gold:"/>
            <Label Grid.Row="3" Grid.Column="1" Content="{Binding CurrentPlayer.Gold}"/>
            <Label Grid.Row="4" Grid.Column="0" Content="XP:"/>
            <Label Grid.Row="4" Grid.Column="1" Content="{Binding CurrentPlayer.ExperiencePoints}"/>
            <Label Grid.Row="5" Grid.Column="0" Content="Level:"/>
            <Label Grid.Row="5" Grid.Column="1" Content="{Binding CurrentPlayer.Level}"/>
        </Grid>

        <!-- Gameplay -->
        <Grid Grid.Row="1" Grid.Column="1"
              Background="Beige">
            <Grid.RowDefinitions>
                <RowDefinition Height="*"/>
                <RowDefinition Height="*"/>
            </Grid.RowDefinitions>

            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="3*"/>
                <ColumnDefinition Width="2*"/>
            </Grid.ColumnDefinitions>

            <!-- Game Messages -->
            <Border Grid.Row="0" Grid.Column="0"
                    Grid.RowSpan="2"
                    BorderBrush="Gainsboro"
                    BorderThickness="1">

                <RichTextBox x:Name="GameMessages"
                             Background="Beige"
                             VerticalScrollBarVisibility="Auto">
                    <RichTextBox.Resources>
                        <Style TargetType="{x:Type Paragraph}">
                            <Setter Property="Margin" Value="0"/>
                        </Style>
                    </RichTextBox.Resources>
                </RichTextBox>

            </Border>

            <!-- Location information -->
            <Border Grid.Row="0" Grid.Column="1"
                    BorderBrush="Gainsboro"
                    BorderThickness="1">

                <Grid Margin="3">
                    <Grid.RowDefinitions>
                        <RowDefinition Height="Auto"/>
                        <RowDefinition Height="*"/>
                        <RowDefinition Height="Auto"/>
                    </Grid.RowDefinitions>

                    <TextBlock Grid.Row="0"
                               HorizontalAlignment="Center"
                               Text="{Binding CurrentLocation.Name}"/>

                    <Image Grid.Row="1"
                           HorizontalAlignment="Center"
                           VerticalAlignment="Center"
                           Height="125"
                           Width="125"
                           Source="{Binding CurrentLocation.ImageName}"/>

                    <TextBlock Grid.Row="2"
                               HorizontalAlignment="Center"
                               Text="{Binding CurrentLocation.Description}"
                               TextWrapping="Wrap"/>
                </Grid>

            </Border>

            <!-- Monster information -->
            <Border Grid.Row="1" Grid.Column="1"
                    BorderBrush="Gainsboro"
                    BorderThickness="1">

                <Grid>
                    <Grid.RowDefinitions>
                        <RowDefinition Height="Auto" />
                        <RowDefinition Height="*" />
                        <RowDefinition Height="Auto" />
                    </Grid.RowDefinitions>

                    <TextBlock Grid.Row="0"
                               HorizontalAlignment="Center"
                               Height="Auto"
                               Text="{Binding CurrentMonster.Name}" />

                    <Image Grid.Row="1"
                           HorizontalAlignment="Center"
                           VerticalAlignment="Center"
                           Height="125"
                           Width="125"
                           Source="{Binding CurrentMonster.ImageName}" />

                    <StackPanel Grid.Row="2"
                                Visibility="{Binding HasMonster, Converter={StaticResource BooleanToVisibility}}"
                                HorizontalAlignment="Center"
                                Orientation="Horizontal">
                        <TextBlock>Current Hit Points:</TextBlock>
                        <TextBlock Text="{Binding CurrentMonster.HitPoints}" />
                    </StackPanel>

                </Grid>

            </Border>

        </Grid>

        <!-- Inventory and Quests -->
        <Grid Grid.Row="2" Grid.Column="0"
              Background="BurlyWood">

            <TabControl>
                <TabItem Header="Inventory">
                    <DataGrid ItemsSource="{Binding CurrentPlayer.Inventory}"
                              AutoGenerateColumns="False"
                              HeadersVisibility="Column">
                        <DataGrid.Columns>
                            <DataGridTextColumn Header="Description"
                                                Binding="{Binding Name}"
                                                Width="*"/>
                            <DataGridTextColumn Header="Price"
                                                Binding="{Binding Price}"
                                                Width="Auto"/>
                        </DataGrid.Columns>
                    </DataGrid>
                </TabItem>

                <TabItem Header="Quests">
                    <DataGrid ItemsSource="{Binding CurrentPlayer.Quests}"
                              AutoGenerateColumns="False"
                              HeadersVisibility="Column">
                        <DataGrid.Columns>
                            <DataGridTextColumn Header="Name"
                                                Binding="{Binding PlayerQuest.Name}"
                                                Width="*"/>
                            <DataGridTextColumn Header="Done?"
                                                Binding="{Binding IsCompleted}"
                                                Width="Auto"/>
                        </DataGrid.Columns>
                    </DataGrid>
                </TabItem>
            </TabControl>
        </Grid>

        <!-- Action controls -->
        <Grid Grid.Row="2" Grid.Column="1"
              Background="Lavender">

            <Grid.RowDefinitions>
                <RowDefinition Height="*" />
            </Grid.RowDefinitions>

            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="*" />
                <ColumnDefinition Width="255" />
            </Grid.ColumnDefinitions>

            <!-- Combat Controls -->
            <Grid Grid.Row="0" Grid.Column="0"
                  Visibility="{Binding HasMonster, Converter={StaticResource BooleanToVisibility}}"
                  HorizontalAlignment="Center"
                  VerticalAlignment="Center">
                <Grid.RowDefinitions>
                    <RowDefinition Height="Auto"/>
                    <RowDefinition Height="Auto"/>
                </Grid.RowDefinitions>

                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="150"/>
                    <ColumnDefinition Width="10"/>
                    <ColumnDefinition Width="50"/>
                </Grid.ColumnDefinitions>

                <ComboBox Grid.Row="0" Grid.Column="0"
                          ItemsSource="{Binding CurrentPlayer.Weapons}"
                          SelectedItem="{Binding CurrentWeapon}"
                          DisplayMemberPath="Name"
                          SelectedValuePath="ID"/>

                <Button Grid.Row="0" Grid.Column="2"
                        Content="Use"
                        Click="OnClick_AttackMonster"/>
            </Grid>

            <!-- Movement Controls -->
            <Grid Grid.Row="0" Grid.Column="1">

                <Grid.RowDefinitions>
                    <RowDefinition Height="*" />
                    <RowDefinition Height="*" />
                    <RowDefinition Height="*" />
                </Grid.RowDefinitions>

                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="*" />
                    <ColumnDefinition Width="*" />
                    <ColumnDefinition Width="*" />
                </Grid.ColumnDefinitions>

                <Button Grid.Row="0" Grid.Column="1" 
                        Height="25" Width="65" Margin="10" 
                        Click="OnClick_MoveNorth"
                        Visibility="{Binding HasLocationToNorth, Converter={StaticResource BooleanToVisibility}}"
                        Content="North"/>
                <Button Grid.Row="1" Grid.Column="0" 
                        Height="25" Width="65" Margin="10" 
                        Click="OnClick_MoveWest"
                        Visibility="{Binding HasLocationToWest, Converter={StaticResource BooleanToVisibility}}"
                        Content="West"/>
                <Button Grid.Row="1" Grid.Column="2" 
                        Height="25" Width="65" Margin="10" 
                        Click="OnClick_MoveEast"
                        Visibility="{Binding HasLocationToEast, Converter={StaticResource BooleanToVisibility}}"
                        Content="East"/>
                <Button Grid.Row="2" Grid.Column="1" 
                        Height="25" Width="65" Margin="10" 
                        Click="OnClick_MoveSouth"
                        Visibility="{Binding HasLocationToSouth, Converter={StaticResource BooleanToVisibility}}"
                        Content="South"/>

            </Grid>

        </Grid>

    </Grid>
</Window>

 

MainWindow.xaml.cs

using System.Windows;
using System.Windows.Documents;
using Engine.EventArgs;
using Engine.ViewModels;

namespace WPFUI
{
    /// <summary>
    /// Interaction logic for MainWindow.xaml
    /// </summary>
    public partial class MainWindow : Window
    {
        private GameSession _gameSession;

        public MainWindow()
        {
            InitializeComponent();

            _gameSession = new GameSession();

            _gameSession.OnMessageRaised += OnGameMessageRaised;

            DataContext = _gameSession;
        }

        private void OnClick_MoveNorth(object sender, RoutedEventArgs e)
        {
            _gameSession.MoveNorth();
        }

        private void OnClick_MoveWest(object sender, RoutedEventArgs e)
        {
            _gameSession.MoveWest();
        }

        private void OnClick_MoveEast(object sender, RoutedEventArgs e)
        {
            _gameSession.MoveEast();
        }

        private void OnClick_MoveSouth(object sender, RoutedEventArgs e)
        {
            _gameSession.MoveSouth();
        }

        private void OnClick_AttackMonster(object sender, RoutedEventArgs e)
        {
            _gameSession.AttackCurrentMonster();
        }

        private void OnGameMessageRaised(object sender, GameMessageEventArgs e)
        {
            GameMessages.Document.Blocks.Add(new Paragraph(new Run(e.Message)));
            GameMessages.ScrollToEnd();
        }
    }
}

 

Step 5: Edit Engine\ViewModels\GameSession.cs

Now, everything is ready to add the logic for the player to fight monsters.

The first thing we need to do is add the CurrentWeapon property on line 57. We don’t have a backing variable for this property because it will only ever be changed from the UI.

If we ever want to change the value from the ViewModel, or Model, we would need to raise a property changed event, to let the UI know. But, we aren’t going to do that now.

 

Next, we need to do is give the player a weapon. Inside the GameSession constructor, on lines 107 through 110, we check the player’s Weapons property. If there are not any objects in that property, we will get a Pointy Stick (item 1001) from the ItemFactory, and give it to the player.

 

Then, we will finally write the combat function “AttackCurrentMonster” (lines 165 through 234). This function is longer than I like functions to be. But, we will do our refactoring (clean-up) after we get the code working.

On lines 167-171, we check if there is no weapon selected. If there isn’t, we use the RaiseMessage function to display the message in the RichTextBox in the UI. Then, we return from the function on line 170.

This is sometimes called “early exit”. If there is something that will prevent the rest of the function from working, we return from the function before we try to run the rest of the function. This is a common pattern for handling validation that you have all the values you need.

On line 174, we get the damage to do to the monster. And, on lines 176 through 184, we raise a message about how much damage the player did to the monster (or if they didn’t do any damage). On line 182, we also subtract the damage from the monster’s hit points.

If you haven’t seen it before, “-=” is another way to say, “CurrentMonster.HitPoints = CurrentMonster.HitPoints – damage”. This line takes the value of the HitPoints property, subtracts the damage, and assigns the results back into the HitPoints property. This is like the “+=” we used in the Location class, to add together the ChanceOfEncountering for all the monsters in the MonstersHere list.

On lines 187 through 207, we handle the player defeating the monster.

If the monster’s hit points are at 0 (or less), we give the player the rewards for defeating the monster, and call RaiseMessage to show the rewards on the UI.

On line 206, we call GetMonsterAtLocation(), so the player has a new monster to fight.

The “else” condition on line 208 is to handle when the monster is still alive. Now, it’s time for the monster to attack.

The logic for the monster attack is like the logic for the player attack. We get a random amount of damage, subtract hit points (if the damage was not zero), and display some messages.

However, if the monster defeats the player, the monster is not rewarded. Instead, on lines 223 through 231, we display a message that the player was killed, we move the player back to their home, and we completely heal the player – so they can return to battle.

In the future, we can make this more complex – determining who attacks first, allowing damage over time (from poison or spells), etc. However, this is what we will start with.

 

GameSession.cs

using System;
using System.Linq;
using Engine.EventArgs;
using Engine.Factories;
using Engine.Models;

namespace Engine.ViewModels
{
    public class GameSession : BaseNotificationClass
    {
        public event EventHandler<GameMessageEventArgs> OnMessageRaised;

        #region Properties

        private Location _currentLocation;
        private Monster _currentMonster;

        public World CurrentWorld { get; set; }
        public Player CurrentPlayer { get; set; }

        public Location CurrentLocation
        {
            get { return _currentLocation; }
            set
            {
                _currentLocation = value;

                OnPropertyChanged(nameof(CurrentLocation));
                OnPropertyChanged(nameof(HasLocationToNorth));
                OnPropertyChanged(nameof(HasLocationToEast));
                OnPropertyChanged(nameof(HasLocationToWest));
                OnPropertyChanged(nameof(HasLocationToSouth));

                GivePlayerQuestsAtLocation();
                GetMonsterAtLocation();
            }
        }

        public Monster CurrentMonster
        {
            get { return _currentMonster; }
            set
            {
                _currentMonster = value;

                OnPropertyChanged(nameof(CurrentMonster));
                OnPropertyChanged(nameof(HasMonster));

                if (CurrentMonster != null)
                {
                    RaiseMessage("");
                    RaiseMessage($"You see a {CurrentMonster.Name} here!");
                }
            }
        }

        public Weapon CurrentWeapon { get; set; }

        public bool HasLocationToNorth
        {
            get
            {
                return CurrentWorld.LocationAt(CurrentLocation.XCoordinate, CurrentLocation.YCoordinate + 1) != null;
            }
        }

        public bool HasLocationToEast
        {
            get
            {
                return CurrentWorld.LocationAt(CurrentLocation.XCoordinate + 1, CurrentLocation.YCoordinate) != null;
            }
        }

        public bool HasLocationToSouth
        {
            get
            {
                return CurrentWorld.LocationAt(CurrentLocation.XCoordinate, CurrentLocation.YCoordinate - 1) != null;
            }
        }

        public bool HasLocationToWest
        {
            get
            {
                return CurrentWorld.LocationAt(CurrentLocation.XCoordinate - 1, CurrentLocation.YCoordinate) != null;
            }
        }

        public bool HasMonster => CurrentMonster != null;

        #endregion

        public GameSession()
        {
            CurrentPlayer = new Player
                            {
                                Name = "Scott",
                                CharacterClass = "Fighter",
                                HitPoints = 10,
                                Gold = 1000000,
                                ExperiencePoints = 0,
                                Level = 1
                            };

            if (!CurrentPlayer.Weapons.Any())
            {
                CurrentPlayer.AddItemToInventory(ItemFactory.CreateGameItem(1001));
            }

            CurrentWorld = WorldFactory.CreateWorld();

            CurrentLocation = CurrentWorld.LocationAt(0, 0);
        }

        public void MoveNorth()
        {
            if(HasLocationToNorth)
            {
                CurrentLocation = CurrentWorld.LocationAt(CurrentLocation.XCoordinate, CurrentLocation.YCoordinate + 1);
            }
        }

        public void MoveEast()
        {
            if(HasLocationToEast)
            {
                CurrentLocation = CurrentWorld.LocationAt(CurrentLocation.XCoordinate + 1, CurrentLocation.YCoordinate);
            }
        }

        public void MoveSouth()
        {
            if(HasLocationToSouth)
            {
                CurrentLocation = CurrentWorld.LocationAt(CurrentLocation.XCoordinate, CurrentLocation.YCoordinate - 1);
            }
        }

        public void MoveWest()
        {
            if(HasLocationToWest)
            {
                CurrentLocation = CurrentWorld.LocationAt(CurrentLocation.XCoordinate - 1, CurrentLocation.YCoordinate);
            }
        }

        private void GivePlayerQuestsAtLocation()
        {
            foreach(Quest quest in CurrentLocation.QuestsAvailableHere)
            {
                if(!CurrentPlayer.Quests.Any(q => q.PlayerQuest.ID == quest.ID))
                {
                    CurrentPlayer.Quests.Add(new QuestStatus(quest));
                }
            }
        }

        private void GetMonsterAtLocation()
        {
            CurrentMonster = CurrentLocation.GetMonster();
        }

        public void AttackCurrentMonster()
        {
            if (CurrentWeapon == null)
            {
                RaiseMessage("You must select a weapon, to attack.");
                return;
            }

            // Determine damage to monster
            int damageToMonster = RandomNumberGenerator.NumberBetween(CurrentWeapon.MinimumDamage, CurrentWeapon.MaximumDamage);

            if (damageToMonster == 0)
            {
                RaiseMessage($"You missed the {CurrentMonster.Name}.");
            }
            else
            {
                CurrentMonster.HitPoints -= damageToMonster;
                RaiseMessage($"You hit the {CurrentMonster.Name} for {damageToMonster} points.");
            }

            // If monster if killed, collect rewards and loot
            if(CurrentMonster.HitPoints <= 0)
            {
                RaiseMessage("");
                RaiseMessage($"You defeated the {CurrentMonster.Name}!");

                CurrentPlayer.ExperiencePoints += CurrentMonster.RewardExperiencePoints;
                RaiseMessage($"You receive {CurrentMonster.RewardExperiencePoints} experience points.");

                CurrentPlayer.Gold += CurrentMonster.RewardGold;
                RaiseMessage($"You receive {CurrentMonster.RewardGold} gold.");

                foreach(ItemQuantity itemQuantity in CurrentMonster.Inventory)
                {
                    GameItem item = ItemFactory.CreateGameItem(itemQuantity.ItemID);
                    CurrentPlayer.AddItemToInventory(item);
                    RaiseMessage($"You receive {itemQuantity.Quantity} {item.Name}.");
                }

                // Get another monster to fight
                GetMonsterAtLocation();
            }
            else
            {
                // If monster is still alive, let the monster attack
                int damageToPlayer = RandomNumberGenerator.NumberBetween(CurrentMonster.MinimumDamage, CurrentMonster.MaximumDamage);

                if (damageToPlayer == 0)
                {
                    RaiseMessage("The monster attacks, but misses you.");
                }
                else
                {
                    CurrentPlayer.HitPoints -= damageToPlayer;
                    RaiseMessage($"The {CurrentMonster.Name} hit you for {damageToPlayer} points.");
                }

                // If player is killed, move them back to their home.
                if (CurrentPlayer.HitPoints <= 0)
                {
                    RaiseMessage("");
                    RaiseMessage($"The {CurrentMonster.Name} killed you.");

                    CurrentLocation = CurrentWorld.LocationAt(0, -1); // Player's home
                    CurrentPlayer.HitPoints = CurrentPlayer.Level * 10; // Completely heal the player
                }
            }
        }

        private void RaiseMessage(string message)
        {
            OnMessageRaised?.Invoke(this, new GameMessageEventArgs(message));
        }
    }
}

 

Step 6: Test the game

Everything should be ready for the player to fight monsters. Run the program, move to a location with a monster, and see if you can defeat it – and collect your gold and loot!

 

Game combat screen - with player and mosnter damage

 

Return to main page

 

33 thoughts on “Lesson 07.4: Monster Combat

  1. Hi Scott,
    I’m having trouble in this step, I can’t set this line in GameSession.cs:

    if (CurrentPlayer.Weapons.Any())
    {
    CurrentPlayer.AddItemToInventory(ItemFactory.CreateGameItem(1001));
    }

    to
    if(!CurrentPlayer.Weapons.Any())

    I’m getting an error of “Value cannot be null”. The weapon comboBox doesn’t populate any weapons for the player.

    1. Hi Rocio,

      I found the source of the problem. The GameItem.cs constructor has this code:

      itemTypeID = ItemTypeID;
      name = Name;
      price = Price;

      Those lines are setting the values of the parameters to the values of the GameItem properties. It needs to be the opposite – the properties need to be set to the parameter values. like this:

      ItemTypeID = itemTypeID;
      Name = name;
      Price = price;

      Please tell m if that does not fix the error, or if you have any questions.

  2. Hi Scott,
    I have a few questions for you.
    * Why did you choose to make the Weapons object a List instead of an ObservableCollection?

    * If we create the AddItemToInventory function, do we need to prevent the use of Inventory.Add() outside of the Player class (is that even possible)?

    * Originally (07.1) Monster.HitPoints had a private set. Did I miss where this changed?

    1. Hi Derrick,

      I made the Weapons property a List because LINQ predicates, the “Where(i => i.Category == GameItem.ItemCategory.Weapon)” section of code, return an IEnumerable result – not an ObservableCollection. I can easily cast IEnumerable to a List, using the ToList() function. However, to cast it to an ObservableCollection, I would need to instantiate a new ObservableCollection object. That would create a problem with the databinding, since the bound ObservableCollection would be replaced with a new object. So, I just made it a List and manually raise the PropertyChanged notification event. There is some more information on another way to solve this problem here: https://stackoverflow.com/questions/10481734/observablecollection-not-updated-when-doing-a-second-linq-query

      It would be nice to eliminate the Add function, to force using the AddItemToInventory function. To do that, we would probably need to create our InventoryCollection class that eliminates the Add function, includes our AddItemToInventory function, and does the propertychanged notifications we need. The ObservableCollection class inherits from the Collection class, which is where the Add function exists. It would make our code more encapsulated. But, it’s a lot of work, and I think I’ve only done that once in a “real” program.

      Removing the “private” on the Monster HitPoints probably happened with some ReSharper cleanup, and wasn’t intentional. However, we’re going to move the HitPoints property again in Lesson 10.1.

  3. Hi Scott,

    I also see a (future) problem.
    After killing the monster we loop through all ItemQuantity’s the Monster has, but for each of them only add 1 item to the inventory, no matter what the quantity in ItemQuantity is (although we do tell the player they get that quantity).

    At this moment the maximum quantity a monster has of an item is 1, so it works for now.

      1. I just arrived at the refactoring chapter and it indeed changes the Monster Inventory from ItemQuantity to GameItems.

        What we didn’t do is talk about the (at the moment) useless class of ItemQuantity.
        Of course leaving it in doesn’t do anything, but it might create an opportunity to create errors by accidentally using ItemQuantities somewhere.

        So unless there is a plan to use it in the future, isn’t it safer to remove it or at least make sure that using it results in something that tells us we’re using it?

        Also, on another lesson (an old one about Moving). I refactored it to using only 1 event in the MainWindow (OnClick_Move) which triggers a new Move function in gamesession and sends the sender cast as a Button. In the new function I have a switch, which runs through all the button names (I named the buttons) and does the functionality we previously had in the 4 seperate Move functions.

        Personally I find that a bit cleaner, also only 1 function to change if we decide to do a climb up/down stairs or in/out buildings.

        1. Martin, we will use the ItemQuantity in several other places.

          I like the idea about other movement directions. When you get to the lessons where we change the weapons and healing potions to use the command pattern object, we could do the same type of thing with directions – making it easier for a location to go up, down, or to another dimension.

  4. Hello Scott, is there any way to change the font color of the RaiseMessage specifically for defeating an enemy, or being slain?

  5. Hi Scott,
    I am having trouble with the ComboBox. The Pointy Stick does not display. But it does display in the inventory box in the left column in the Inventory tab. Wondering why it might not be displaying in the ComboBox?

    Thanks

  6. Hallo, thanks for this great tuto it’s really nice 🙂

    i think there is a litle problem actualy:
    on GameSession line 174 and 211 we roll between minimumDamage and MaximumDamage so 0 can’t happen and we can’t miss so the next if(==0) never happen and it’s sad ^^ or i have miss something :p

    1. You’re welcome!

      You are correct. With the weapons at this time, it is not possible to do zero damage (or miss). In a future lesson, we do create some Weapons that have a MinimumDamage of zero. I’d also like to make the combat more complex, where an attack can miss – and have the chance of a miss increase, due to the opponent being a higher level or having armor. But, you can always change the MinimumDamage for the Weapons you create in your version of the game.

  7. Hey Scott, I learned from your videos so much, thank you!!!
    Just a small detail I noticed, in line 31 & 39 in the Monster.cs you wrote maxmumDamage instead of maximumDamage.
    Funnily enough, since you made the same typo in both places the program run with no errors, but the mistake bugged me regardless so I thought to mention it.
    Keep up the good work, I enjoy your guide a lot!
    Btw, later (lesson 12.2) you fix this typo in the AttackWithWeapon.cs.

  8. Hi Scott! I found your lesson very easy to follow and I am really glad I came to this lesson and finally players can fight with the monsters! However I found nothing is in my combo box and under the inventory list which is weird to me. I upload my code here for you to take a look: https://github.com/jun383914/GameLearning Thank you!!

    1. Hi Zhen,

      In the GameSession constructor, after creating the CurrentPlayer, we have the code below to give the player a pointy stick. I’m currently working on a new lesson to save and load the game, so we can maintain the player’s inventory when you close and re-open the game.


      if (!CurrentPlayer.Weapons.Any())
      {
      CurrentPlayer.AddItemToInventory(ItemFactory.CreateGameItem(1001));
      }

      Let me know if that doesn’t work, or if you have any other questions.

  9. Hey Scott,

    I’m loving this tutorial! I was wondering if there was a particular reason that we stored the CurrrentWeapon variable in the GameSession class? It seems more intuitive to me that this should be a feature of the player class.

    Is there an advantage to doing it this way? Or is it arbitrary?

    Thanks in advance!

    1. Thanks Julie!

      It’s completely arbitrary. I spent a few minutes going back and forth between storing it in the Player or the GameSession class, and finally just picked the GameSession class. It was one of those 50/50 choices, that’s easy to change in the future, so I decided to mentally “flip a coin” and move on.

  10. foreach (ItemQuantity itemQuantity in CurrentMonster.Inventory)
                    {
                        GameItem item = ItemFactory.CreateGameItem(itemQuantity.ItemID);
                        CurrentPlayer.AddItemToInventory(item);
                        RaiseMessage($"You receive {itemQuantity.Quantity} {item.Name}.");
                    }

     

    Hi Scott,

    I get error on last hit on monster:
    In GameSessions.cs on AttackCurrentMonster() function where is foreach.

    Error:

    System.NullReferenceException: ‘Object reference not set to an instance of an object.’

    item was null.

     

    When I debug I get it on the last hit. Can you give some advice where can be problem. Thank you some much

    1. Hello,

      I’m sorry it took so long to reply. I just moved and was having trouble with the internet.

      This might be a problem with the sequence of events firing – the defeated monster object is disposed of before you get its loot. This might be cleaned up in lesson 16, when we move the combat into a new Battle class. That class manages the combat differently.

  11. Hi Scott – thanks for this great learning tool. I’ve been following along over the past week, and I’ve hit a problem here. Combat resolves properly except for an error in the debug log that shows up after attacking while a weapon is selected:

    “System.Windows.Data Error: 40 : BindingExpression path error: ‘ID’ property not found on ‘object’ ”Weapon’ (HashCode=16310625)’. BindingExpression:Path=ID; DataItem=’Weapon’ (HashCode=16310625); target element is ‘ComboBox’ (Name=”); target property is ‘NoTarget’ (type ‘Object’)”

    The binding is here:

    Any advice?

    1. You’re welcome, Chris!

      I think we fix that in a future lesson. If I remember correctly, that should be “ItemTypeID”, instead of “ID”. However, we actually didn’t need to bind the SelectedValuePath (we get the information from the SelectedItem object). So, we just removed that attribute binding.

      You can remove it, or leave it in. It’s a runtime error that is ignored, so it doesn’t break the gameplay.

Leave a Reply

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