Now, we’ll change the Monster’s attack to use the AttackWithWeapon class. To do this, we’ll need to give the Monster a weapon to use.
Step 1: Modify Engine\Factories\ItemFactory.cs
We’ll create two new weapons on lines 17-19 – fangs and a claw.
Right now, we won’t use the weapon description. The important parts are the minimum and maximum damage values that we will use in the BuildWeapon function, where we build the AttackWithWeapon object for the Monster’s “weapon”.
ItemFactory.cs
using System.Collections.Generic;
using System.Linq;
using Engine.Actions;
using Engine.Models;
namespace Engine.Factories
{
public static class ItemFactory
{
private static readonly List<GameItem> _standardGameItems = new List<GameItem>();
static ItemFactory()
{
BuildWeapon(1001, "Pointy Stick", 1, 1, 2);
BuildWeapon(1002, "Rusty Sword", 5, 1, 3);
BuildWeapon(1501, "Snake fangs", 0, 0, 2);
BuildWeapon(1502, "Rat claws", 0, 0, 2);
BuildWeapon(1503, "Spider fangs", 0, 0, 4);
BuildMiscellaneousItem(9001, "Snake fang", 1);
BuildMiscellaneousItem(9002, "Snakeskin", 2);
BuildMiscellaneousItem(9003, "Rat tail", 1);
BuildMiscellaneousItem(9004, "Rat fur", 2);
BuildMiscellaneousItem(9005, "Spider fang", 1);
BuildMiscellaneousItem(9006, "Spider silk", 2);
}
public static GameItem CreateGameItem(int itemTypeID)
{
return _standardGameItems.FirstOrDefault(item => item.ItemTypeID == itemTypeID)?.Clone();
}
private static void BuildMiscellaneousItem(int id, string name, int price)
{
_standardGameItems.Add(new GameItem(GameItem.ItemCategory.Miscellaneous, id, name, price));
}
private static void BuildWeapon(int id, string name, int price,
int minimumDamage, int maximumDamage)
{
GameItem weapon = new GameItem(GameItem.ItemCategory.Weapon, id, name, price, true);
weapon.Action = new AttackWithWeapon(weapon, minimumDamage, maximumDamage);
_standardGameItems.Add(weapon);
}
}
}
Step 2: Modify Engine\Models\Monster.cs
Remove the MinimumDamage and MaximuDamage properties (lines 6-7), along with their parameters in the constructor (line 13) and the lines where the parameters were assigned to the properties (lines 19-20).
Monster.cs
namespace Engine.Models
{
public class Monster : LivingEntity
{
public string ImageName { get; }
public int RewardExperiencePoints { get; }
public Monster(string name, string imageName,
int maximumHitPoints, int currentHitPoints,
int rewardExperiencePoints, int gold) :
base(name, maximumHitPoints, currentHitPoints, gold)
{
ImageName = $"/Engine;component/Images/Monsters/{imageName}";
RewardExperiencePoints = rewardExperiencePoints;
}
}
}
Step 3: Modify Engine\Factories\MonsterFactory.cs
Now, well modify the Monster object creation to give them a CurrentWeapon that uses an IAction object.
Modify the calls to the Monster constructor (lines 14, 23, and 32) to remove the old minimum and maximum damage parameters.
Then, give the Monsters weapons to use on the new lines 16, 27, and 38. Notice that we are not adding these items to the monsters’ loot. We’re only adding them to the CurrentWeapon. So, the Player won’t ever receive these weapons when they defeat a monster and loot their items.
If we wanted the weapon to be lootable, we would need to use the AddLootItem function to put it in the monster’s inventory.
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, 5, 1);
snake.CurrentWeapon = ItemFactory.CreateGameItem(1501);
AddLootItem(snake, 9001, 25);
AddLootItem(snake, 9002, 75);
return snake;
case 2:
Monster rat =
new Monster("Rat", "Rat.png", 5, 5, 5, 1);
rat.CurrentWeapon = ItemFactory.CreateGameItem(1502);
AddLootItem(rat, 9003, 25);
AddLootItem(rat, 9004, 75);
return rat;
case 3:
Monster giantSpider =
new Monster("Giant Spider", "GiantSpider.png", 10, 10, 10, 3);
giantSpider.CurrentWeapon = ItemFactory.CreateGameItem(1503);
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.AddItemToInventory(ItemFactory.CreateGameItem(itemID));
}
}
}
}
Step 4: Modify Engine\ViewModels\GameSession.cs
Now, we’ll switch the monster’s combat logic to use the IAction from its CurrentWeapon.
We’ll do this the same way we do for the Player class. The Monster class inherits from LivingEntity. So, LivingEntity already subscribes to its CurrentWeapon object’s OnActionPerformed event. When the Action raises a message, the LivingEntity object catches it and raises its own OnActionPerformed event that the UI is looking at.
So, we need to have the GameSession class watch the CurrentMonster’s OnActionPerformed event.
Subscribe to the Monster’s action event by adding the new OnCurrentMonsterPerformedAction function (lines 275-278). Subscribe to, and unsubscribe from, the CurrentMonster’s OnActionPerformed event in the CurrentMonster setter on lines 73 and 81.
Then, remove the existing code from lines 265-275 and replace it with the monster’s equivalent to line 255. This will make the CurrentMonster use its CurrentWeapon’s Action when it attacks the CurrentPlayer.
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 Player _currentPlayer;
private Location _currentLocation;
private Monster _currentMonster;
private Trader _currentTrader;
public World CurrentWorld { get; }
public Player CurrentPlayer
{
get { return _currentPlayer; }
set
{
if(_currentPlayer != null)
{
_currentPlayer.OnActionPerformed -= OnCurrentPlayerPerformedAction;
_currentPlayer.OnLeveledUp -= OnCurrentPlayerLeveledUp;
_currentPlayer.OnKilled -= OnCurrentPlayerKilled;
}
_currentPlayer = value;
if (_currentPlayer != null)
{
_currentPlayer.OnActionPerformed += OnCurrentPlayerPerformedAction;
_currentPlayer.OnLeveledUp += OnCurrentPlayerLeveledUp;
_currentPlayer.OnKilled += OnCurrentPlayerKilled;
}
}
}
public Location CurrentLocation
{
get { return _currentLocation; }
set
{
_currentLocation = value;
OnPropertyChanged();
OnPropertyChanged(nameof(HasLocationToNorth));
OnPropertyChanged(nameof(HasLocationToEast));
OnPropertyChanged(nameof(HasLocationToWest));
OnPropertyChanged(nameof(HasLocationToSouth));
CompleteQuestsAtLocation();
GivePlayerQuestsAtLocation();
GetMonsterAtLocation();
CurrentTrader = CurrentLocation.TraderHere;
}
}
public Monster CurrentMonster
{
get { return _currentMonster; }
set
{
if(_currentMonster != null)
{
_currentMonster.OnActionPerformed -= OnCurrentMonsterPerformedAction;
_currentMonster.OnKilled -= OnCurrentMonsterKilled;
}
_currentMonster = value;
if(_currentMonster != null)
{
_currentMonster.OnActionPerformed += OnCurrentMonsterPerformedAction;
_currentMonster.OnKilled += OnCurrentMonsterKilled;
RaiseMessage("");
RaiseMessage($"You see a {CurrentMonster.Name} here!");
}
OnPropertyChanged();
OnPropertyChanged(nameof(HasMonster));
}
}
public Trader CurrentTrader
{
get { return _currentTrader; }
set
{
_currentTrader = value;
OnPropertyChanged();
OnPropertyChanged(nameof(HasTrader));
}
}
public bool HasLocationToNorth =>
CurrentWorld.LocationAt(CurrentLocation.XCoordinate, CurrentLocation.YCoordinate + 1) != null;
public bool HasLocationToEast =>
CurrentWorld.LocationAt(CurrentLocation.XCoordinate + 1, CurrentLocation.YCoordinate) != null;
public bool HasLocationToSouth =>
CurrentWorld.LocationAt(CurrentLocation.XCoordinate, CurrentLocation.YCoordinate - 1) != null;
public bool HasLocationToWest =>
CurrentWorld.LocationAt(CurrentLocation.XCoordinate - 1, CurrentLocation.YCoordinate) != null;
public bool HasMonster => CurrentMonster != null;
public bool HasTrader => CurrentTrader != null;
#endregion
public GameSession()
{
CurrentPlayer = new Player("Scott", "Fighter", 0, 10, 10, 1000000);
if (!CurrentPlayer.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 CompleteQuestsAtLocation()
{
foreach(Quest quest in CurrentLocation.QuestsAvailableHere)
{
QuestStatus questToComplete =
CurrentPlayer.Quests.FirstOrDefault(q => q.PlayerQuest.ID == quest.ID &&
!q.IsCompleted);
if(questToComplete != null)
{
if(CurrentPlayer.HasAllTheseItems(quest.ItemsToComplete))
{
// Remove the quest completion items from the player's inventory
foreach (ItemQuantity itemQuantity in quest.ItemsToComplete)
{
for(int i = 0; i < itemQuantity.Quantity; i++)
{
CurrentPlayer.RemoveItemFromInventory(CurrentPlayer.Inventory.First(item => item.ItemTypeID == itemQuantity.ItemID));
}
}
RaiseMessage("");
RaiseMessage($"You completed the '{quest.Name}' quest");
// Give the player the quest rewards
RaiseMessage($"You receive {quest.RewardExperiencePoints} experience points");
CurrentPlayer.AddExperience(quest.RewardExperiencePoints);
RaiseMessage($"You receive {quest.RewardGold} gold");
CurrentPlayer.ReceiveGold(quest.RewardGold);
foreach(ItemQuantity itemQuantity in quest.RewardItems)
{
GameItem rewardItem = ItemFactory.CreateGameItem(itemQuantity.ItemID);
RaiseMessage($"You receive a {rewardItem.Name}");
CurrentPlayer.AddItemToInventory(rewardItem);
}
// Mark the Quest as completed
questToComplete.IsCompleted = true;
}
}
}
}
private void GivePlayerQuestsAtLocation()
{
foreach(Quest quest in CurrentLocation.QuestsAvailableHere)
{
if(!CurrentPlayer.Quests.Any(q => q.PlayerQuest.ID == quest.ID))
{
CurrentPlayer.Quests.Add(new QuestStatus(quest));
RaiseMessage("");
RaiseMessage($"You receive the '{quest.Name}' quest");
RaiseMessage(quest.Description);
RaiseMessage("Return with:");
foreach(ItemQuantity itemQuantity in quest.ItemsToComplete)
{
RaiseMessage($" {itemQuantity.Quantity} {ItemFactory.CreateGameItem(itemQuantity.ItemID).Name}");
}
RaiseMessage("And you will receive:");
RaiseMessage($" {quest.RewardExperiencePoints} experience points");
RaiseMessage($" {quest.RewardGold} gold");
foreach(ItemQuantity itemQuantity in quest.RewardItems)
{
RaiseMessage($" {itemQuantity.Quantity} {ItemFactory.CreateGameItem(itemQuantity.ItemID).Name}");
}
}
}
}
private void GetMonsterAtLocation()
{
CurrentMonster = CurrentLocation.GetMonster();
}
public void AttackCurrentMonster()
{
if(CurrentPlayer.CurrentWeapon == null)
{
RaiseMessage("You must select a weapon, to attack.");
return;
}
CurrentPlayer.UseCurrentWeaponOn(CurrentMonster);
if(CurrentMonster.IsDead)
{
// Get another monster to fight
GetMonsterAtLocation();
}
else
{
CurrentMonster.UseCurrentWeaponOn(CurrentPlayer);
}
}
private void OnCurrentPlayerPerformedAction(object sender, string result)
{
RaiseMessage(result);
}
private void OnCurrentMonsterPerformedAction(object sender, string result)
{
RaiseMessage(result);
}
private void OnCurrentPlayerKilled(object sender, System.EventArgs eventArgs)
{
RaiseMessage("");
RaiseMessage("You have been killed.");
CurrentLocation = CurrentWorld.LocationAt(0, -1);
CurrentPlayer.CompletelyHeal();
}
private void OnCurrentMonsterKilled(object sender, System.EventArgs eventArgs)
{
RaiseMessage("");
RaiseMessage($"You defeated the {CurrentMonster.Name}!");
RaiseMessage($"You receive {CurrentMonster.RewardExperiencePoints} experience points.");
CurrentPlayer.AddExperience(CurrentMonster.RewardExperiencePoints);
RaiseMessage($"You receive {CurrentMonster.Gold} gold.");
CurrentPlayer.ReceiveGold(CurrentMonster.Gold);
foreach(GameItem gameItem in CurrentMonster.Inventory)
{
RaiseMessage($"You receive one {gameItem.Name}.");
CurrentPlayer.AddItemToInventory(gameItem);
}
}
private void OnCurrentPlayerLeveledUp(object sender, System.EventArgs eventArgs)
{
RaiseMessage($"You are now level {CurrentPlayer.Level}!");
}
private void RaiseMessage(string message)
{
OnMessageRaised?.Invoke(this, new GameMessageEventArgs(message));
}
}
}
Step 5: Modify Engine\Actions\AttackWithWeapon.cs
Finally, we need to change the message from the AttackWithWeapon so it displays the correct measure – whether a Player is attacking a Monster, or a Monster is attacking a Player.
On lines 40 and 41, we create two variables to handle using “You” in the damage report message. This checks to see if the player is the actor or the target, and places “you” in the correct place.
On lines 45 and 49, we change the message text to use these new variables. On line 49, I also added a conditional display of the letter “s”, when the amount of damage is more than one hit point.
AttackWithWeapon.cs
using System;
using Engine.Models;
namespace Engine.Actions
{
public class AttackWithWeapon : IAction
{
private readonly GameItem _weapon;
private readonly int _maximumDamage;
private readonly int _minimumDamage;
public event EventHandler<string> OnActionPerformed;
public AttackWithWeapon(GameItem weapon, int minimumDamage, int maximumDamage)
{
if(weapon.Category != GameItem.ItemCategory.Weapon)
{
throw new ArgumentException($"{weapon.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");
}
_weapon = weapon;
_minimumDamage = minimumDamage;
_maximumDamage = maximumDamage;
}
public void Execute(LivingEntity actor, LivingEntity target)
{
int damage = RandomNumberGenerator.NumberBetween(_minimumDamage, _maximumDamage);
string actorName = (actor is Player) ? "You" : $"The {actor.Name.ToLower()}";
string targetName = (target is Player) ? "you" : $"the {target.Name.ToLower()}";
if(damage == 0)
{
ReportResult($"{actorName} missed {targetName}.");
}
else
{
ReportResult($"{actorName} hit {targetName} for {damage} point{(damage > 1 ? "s" : "")}.");
target.TakeDamage(damage);
}
}
private void ReportResult(string result)
{
OnActionPerformed?.Invoke(this, result);
}
}
}
Step 6: Run the unit tests and test the game
NEXT LESSON: Lesson 12.5: Creating the first consumable GameItem
PREVIOUS LESSON: Lesson 12.3: Making the Action class more flexible with an interface
Thank you for this Tutorial, Genuinely.
I’ve gone through once before, probably 5-6 years ago, on your older version of the tutorial and I’m still amazed at how well done this whole thing is.
You were one of my early forays into coding, and thanks to you, and a few other key developers out there I was able to turn my hobby into enough knowledge to shift into a full-time job. Technically not a perfect fit since it’s 90% with python… but I know for a fact without philanthropic folks like you I would still be stuck in retail!
Thank you for your message, Josh.
You are exactly why I create tutorials like this. My hope is that maybe they will spark an interest for someone, or help them understand something they’re stuck on, so they can continue on with learning programming and maybe lead to a better job (and hopefully, a better life).
Take care!