We have our first GitHub contribution from someone else, DarthPedro – who is also creating a Blazor/Azure version of the game at https://darthpedro.net/tutorials-simple-rpg/.
It’s a fix for two bugs. From now on, I’ll group bug fixes under lesson 99. They’ll be out of the main line of lessons but will be added to the lesson index in the order they should be applied.
These changes mostly fix errors with event subscriptions.
Step 1: Modify Engine\ViewModels\GameSession.cs
One of the nice things about GitHub is that you can see the changes made in a pull request. To view the changes Pedro submitted, you can go here: https://github.com/SOSCSRPG/SOSCSRPG/pull/14/files
The change sets the _currentBattle variable to null on line 78, when we set a new CurrentMonster – to ensure the object is completely cleared out.
Adding the question mark in the AttackCurrentMonster function will prevent us from trying to run the AttackOpponent function if the _currentBattle is null.
In the UseCurrentConsumable function, Pedro added a subscription to a new OnConsumableActionPerformed function (see lines 286-289) to ensure the messages for using consumable items works if the player isn’t in a battle.
GameSession.cs
using System.Linq;
using Engine.Factories;
using Engine.Models;
using Engine.Services;
using Newtonsoft.Json;
namespace Engine.ViewModels
{
public class GameSession : BaseNotificationClass
{
private readonly MessageBroker _messageBroker = MessageBroker.GetInstance();
#region Properties
private Player _currentPlayer;
private Location _currentLocation;
private Battle _currentBattle;
private Monster _currentMonster;
private Trader _currentTrader;
public string Version { get; } = "0.1.000";
[JsonIgnore]
public World CurrentWorld { get; }
public Player CurrentPlayer
{
get => _currentPlayer;
set
{
if(_currentPlayer != null)
{
_currentPlayer.OnLeveledUp -= OnCurrentPlayerLeveledUp;
_currentPlayer.OnKilled -= OnPlayerKilled;
}
_currentPlayer = value;
if(_currentPlayer != null)
{
_currentPlayer.OnLeveledUp += OnCurrentPlayerLeveledUp;
_currentPlayer.OnKilled += OnPlayerKilled;
}
}
}
public Location CurrentLocation
{
get => _currentLocation;
set
{
_currentLocation = value;
OnPropertyChanged();
OnPropertyChanged(nameof(HasLocationToNorth));
OnPropertyChanged(nameof(HasLocationToEast));
OnPropertyChanged(nameof(HasLocationToWest));
OnPropertyChanged(nameof(HasLocationToSouth));
CompleteQuestsAtLocation();
GivePlayerQuestsAtLocation();
CurrentMonster = CurrentLocation.GetMonster();
CurrentTrader = CurrentLocation.TraderHere;
}
}
[JsonIgnore]
public Monster CurrentMonster
{
get => _currentMonster;
set
{
if(_currentBattle != null)
{
_currentBattle.OnCombatVictory -= OnCurrentMonsterKilled;
_currentBattle.Dispose();
_currentBattle = null;
}
_currentMonster = value;
if(_currentMonster != null)
{
_currentBattle = new Battle(CurrentPlayer, CurrentMonster);
_currentBattle.OnCombatVictory += OnCurrentMonsterKilled;
}
OnPropertyChanged();
OnPropertyChanged(nameof(HasMonster));
}
}
[JsonIgnore]
public Trader CurrentTrader
{
get => _currentTrader;
set
{
_currentTrader = value;
OnPropertyChanged();
OnPropertyChanged(nameof(HasTrader));
}
}
[JsonIgnore]
public bool HasLocationToNorth =>
CurrentWorld.LocationAt(CurrentLocation.XCoordinate, CurrentLocation.YCoordinate + 1) != null;
[JsonIgnore]
public bool HasLocationToEast =>
CurrentWorld.LocationAt(CurrentLocation.XCoordinate + 1, CurrentLocation.YCoordinate) != null;
[JsonIgnore]
public bool HasLocationToSouth =>
CurrentWorld.LocationAt(CurrentLocation.XCoordinate, CurrentLocation.YCoordinate - 1) != null;
[JsonIgnore]
public bool HasLocationToWest =>
CurrentWorld.LocationAt(CurrentLocation.XCoordinate - 1, CurrentLocation.YCoordinate) != null;
[JsonIgnore]
public bool HasMonster => CurrentMonster != null;
[JsonIgnore]
public bool HasTrader => CurrentTrader != null;
#endregion
public GameSession()
{
CurrentWorld = WorldFactory.CreateWorld();
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));
CurrentLocation = CurrentWorld.LocationAt(0, 0);
}
public GameSession(Player player, int xCoordinate, int yCoordinate)
{
CurrentWorld = WorldFactory.CreateWorld();
CurrentPlayer = player;
CurrentLocation = CurrentWorld.LocationAt(xCoordinate, yCoordinate);
}
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.Inventory.HasAllTheseItems(quest.ItemsToComplete))
{
CurrentPlayer.RemoveItemsFromInventory(quest.ItemsToComplete);
_messageBroker.RaiseMessage("");
_messageBroker.RaiseMessage($"You completed the '{quest.Name}' quest");
// Give the player the quest rewards
_messageBroker.RaiseMessage($"You receive {quest.RewardExperiencePoints} experience points");
CurrentPlayer.AddExperience(quest.RewardExperiencePoints);
_messageBroker.RaiseMessage($"You receive {quest.RewardGold} gold");
CurrentPlayer.ReceiveGold(quest.RewardGold);
foreach(ItemQuantity itemQuantity in quest.RewardItems)
{
GameItem rewardItem = ItemFactory.CreateGameItem(itemQuantity.ItemID);
_messageBroker.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));
_messageBroker.RaiseMessage("");
_messageBroker.RaiseMessage($"You receive the '{quest.Name}' quest");
_messageBroker.RaiseMessage(quest.Description);
_messageBroker.RaiseMessage("Return with:");
foreach(ItemQuantity itemQuantity in quest.ItemsToComplete)
{
_messageBroker
.RaiseMessage($" {itemQuantity.Quantity} {ItemFactory.CreateGameItem(itemQuantity.ItemID).Name}");
}
_messageBroker.RaiseMessage("And you will receive:");
_messageBroker.RaiseMessage($" {quest.RewardExperiencePoints} experience points");
_messageBroker.RaiseMessage($" {quest.RewardGold} gold");
foreach(ItemQuantity itemQuantity in quest.RewardItems)
{
_messageBroker
.RaiseMessage($" {itemQuantity.Quantity} {ItemFactory.CreateGameItem(itemQuantity.ItemID).Name}");
}
}
}
}
public void AttackCurrentMonster()
{
_currentBattle?.AttackOpponent();
}
public void UseCurrentConsumable()
{
if(CurrentPlayer.CurrentConsumable != null)
{
if (_currentBattle == null)
{
CurrentPlayer.OnActionPerformed += OnConsumableActionPerformed;
}
CurrentPlayer.UseCurrentConsumable();
if (_currentBattle == null)
{
CurrentPlayer.OnActionPerformed -= OnConsumableActionPerformed;
}
}
}
private void OnConsumableActionPerformed(object sender, string result)
{
_messageBroker.RaiseMessage(result);
}
public void CraftItemUsing(Recipe recipe)
{
if(CurrentPlayer.Inventory.HasAllTheseItems(recipe.Ingredients))
{
CurrentPlayer.RemoveItemsFromInventory(recipe.Ingredients);
foreach(ItemQuantity itemQuantity in recipe.OutputItems)
{
for(int i = 0; i < itemQuantity.Quantity; i++)
{
GameItem outputItem = ItemFactory.CreateGameItem(itemQuantity.ItemID);
CurrentPlayer.AddItemToInventory(outputItem);
_messageBroker.RaiseMessage($"You craft 1 {outputItem.Name}");
}
}
}
else
{
_messageBroker.RaiseMessage("You do not have the required ingredients:");
foreach(ItemQuantity itemQuantity in recipe.Ingredients)
{
_messageBroker
.RaiseMessage($" {itemQuantity.Quantity} {ItemFactory.ItemName(itemQuantity.ItemID)}");
}
}
}
private void OnPlayerKilled(object sender, System.EventArgs e)
{
_messageBroker.RaiseMessage("");
_messageBroker.RaiseMessage("You have been killed.");
CurrentLocation = CurrentWorld.LocationAt(0, -1);
CurrentPlayer.CompletelyHeal();
}
private void OnCurrentMonsterKilled(object sender, System.EventArgs eventArgs)
{
// Get another monster to fight
CurrentMonster = CurrentLocation.GetMonster();
}
private void OnCurrentPlayerLeveledUp(object sender, System.EventArgs eventArgs)
{
_messageBroker.RaiseMessage($"You are now level {CurrentPlayer.Level}!");
}
}
}
NEXT LESSON: Lesson 99.2: Replace random number library
PREVIOUS LESSON: Lesson 17.4: Automated test for saved game reader
I’m having another bug with combat. If a player is on low hp and kills a monster, and the next monster that spawns gets to attack first, and kills the player with that first attack, the player will still be in a battle with the monster after respawning at their home. I think this is because the attack (and the player’s death) happen before the constructor for the new battle has finished. I modified the CurrentMonster setter to check if the player is at their home after the new battle has been created, and if so, dispose the battle and set the battle and monster to null. This seems to have fixed the problem, but I would be interested to hear how you would fix the bug, assuming this isn’t just something unique to my code.
That sounds like a good solution. One problem with events is managing the sequence of events that fire at the same time (or almost the same time), or multiple events that need to act like one event (defeating a monster gives you loot, XP, gold, instantiates a new monster, etc.). There is enough going on in the game that we have these types of issues, but it’s a small enough program that I’ve tried to avoid some sort of “serious” event manager/queue. So, your solution sounds good, and doesn’t get into a more-complex architecture.