Lesson 3.11: Adding Quests to Locations

Quests are a core concept of any roleplaying game. Various people send our player off on quests to kill some monsters, return with items for potions, retrieve the secret stone, and so much more. These provide some of the motivations for our heroes to adventure. Then when the player completes the quests, they may get a key, gold, or magical items that open up further storylines. So we are going to introduce quests into our game engine.

Introducing the Quest Model

As a pre-requisite for the Quest class. We are going to implement the ItemQuantity class in the SimpleRPG.Game.Engine project and Models folder:

namespace SimpleRPG.Game.Engine.Models
{
    public class ItemQuantity
    {
        public int ItemId { get; set; }

        public int Quantity { get; set; }
    }
}

This simple model class lets us hold item ids and the number of those items. We will use this class in the Quest class to hold the requirements and rewards items for the quest.

Then we create the Quest class in the same folder (SimpleRPG.Game.Engine project and Models folder):

using System;
using System.Collections.Generic;

namespace SimpleRPG.Game.Engine.Models
{
    public class Quest
    {
        public int Id { get; set; }

        public string Name { get; set; } = string.Empty;
        
        public string Description { get; set; } = string.Empty;

        public IList<ItemQuantity> ItemsToComplete { get; set; } = Array.Empty<ItemQuantity>();

        public int RewardExperiencePoints { get; set; }
        
        public int RewardGold { get; set; }
        
        public IList<ItemQuantity> RewardItems { get; set; } = Array.Empty<ItemQuantity>();
    }
}
  1. Every quest has an id, name, and description… we use these properties in many of our model entities.
  2. Then it has the ItemsToComplete property. This is a list of all of the items the player must have in their inventory in order to be able to complete the quest. This list of prerequisites uses the ItemQuantity class in the case that we need multiple things in the quest.
  3. There are a set of rewards that will be given when the quest is completed:
    • Experience points – to help improve the player’s level.
    • Gold – to help player buy better equipment.
    • Rewards items – other items exchanged for what was consumed to complete this quest.

This is a straightforward model class without much logic, but it let’s us define quests and add them to locations in the game and to the player’s Quests list.

Now that we have the Quest data model defined, we need to create a QuestFactory to get instances of quests based upon their id. We create this class in the SimpleRPG.Game.Engine project and the Factories folder.

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

namespace SimpleRPG.Game.Engine.Factories
{
    internal static class QuestFactory
    {
        private static readonly IList<Quest> _quests = new List<Quest>();

        static QuestFactory()
        {
            // Declare the items need to complete the quest, and its reward items
            List<ItemQuantity> itemsToComplete = new List<ItemQuantity>();
            List<ItemQuantity> rewardItems = new List<ItemQuantity>();

            itemsToComplete.Add(new ItemQuantity { ItemId = 9001, Quantity = 5 });
            rewardItems.Add(new ItemQuantity { ItemId = 1002, Quantity = 1 });

            // Create the quest
            _quests.Add(new Quest
            {
                Id = 1,
                Name = "Clear the herb garden",
                Description = "Defeat the snakes in the Herbalist's garden",
                ItemsToComplete = itemsToComplete,
                RewardGold = 25,
                RewardExperiencePoints = 10,
                RewardItems = rewardItems
            });
        }

        public static Quest GetQuestById(int id)
        {
            return _quests.First(quest => quest.Id == id);
        }
    }
}

The QuestFactory follows the pattern of the other factories in our game (ItemFactory, MonsterFactory, TraderFactory). It creates a static list of the known quests in the game. Then, provides the GetQuestById method to retrieve an instance of a particular quest. We start with one simple quest.

Adding Quests to Locations

With the model and factory in place, we need to add quests to the Location and Player class, and then position the initial quest into our game world.

1. As we did with traders, we need to add quests to the Location model class. We do that by adding the QuestsAvailableHere property:

using SimpleRPG.Game.Engine.Factories;
using SimpleRPG.Game.Engine.Services;
using System;
using System.Collections.Generic;
using System.Linq;

namespace SimpleRPG.Game.Engine.Models
{
    public class Location
    {
        public int XCoordinate { get; set; }

        public int YCoordinate { get; set; }

        public string Name { get; set; } = string.Empty;

        public string Description { get; set; } = string.Empty;

        public string ImageName { get; set; } = string.Empty;

        public IList<Quest> QuestsAvailableHere { get; set; } = new List<Quest>();

        public IList<MonsterEncounter> MonstersHere { get; set; } = new List<MonsterEncounter>();

        public Trader? TraderHere { get; set; } = null;

        public bool HasTrader => TraderHere != null;

        public void AddMonsterEncounter(int monsterId, int chanceOfEncountering)
        {
            if (MonstersHere.Any(m => m.MonsterId == monsterId))
            {
                // this monster has already been added to this location.
                // so overwrite the ChanceOfEncountering with the new number.
                MonstersHere.First(m => m.MonsterId == monsterId)
                            .ChanceOfEncountering = chanceOfEncountering;
            }
            else
            {
                // this monster is not already at this location, so add it.
                MonstersHere.Add(new MonsterEncounter(monsterId, chanceOfEncountering));
            }
        }

        public bool HasMonster() => MonstersHere.Any();

        public Monster GetMonster()
        {
            if (HasMonster() == false)
            {
                throw new InvalidOperationException();
            }

            // total the percentages of all monsters at this location.
            int totalChances = MonstersHere.Sum(m => m.ChanceOfEncountering);

            // Select a random number between 1 and the total (in case the total chances is not 100).
            var result = DiceService.Instance.Roll(totalChances);

            // loop through the monster list, 
            // adding the monster's percentage chance of appearing to the runningTotal variable.
            // when the random number is lower than the runningTotal, that is the monster to return.
            int runningTotal = 0;

            foreach (MonsterEncounter monsterEncounter in MonstersHere)
            {
                runningTotal += monsterEncounter.ChanceOfEncountering;

                if (result.Value <= runningTotal)
                {
                    return MonsterFactory.GetMonster(monsterEncounter.MonsterId);
                }
            }

            // If there was a problem, return the last monster in the list.
            return MonsterFactory.GetMonster(MonstersHere.Last().MonsterId);
        }
    }
}

2. Then we update the WorldFactory to place a quest at the Herbalist’s hut:

using SimpleRPG.Game.Engine.Models;
using System.Collections.Generic;

namespace SimpleRPG.Game.Engine.Factories
{
    internal static class WorldFactory
    {
        internal static World CreateWorld()
        {
            var locations = new List<Location>
            {
                new Location
                {
                    XCoordinate = -2,
                    YCoordinate = -1,
                    Name = "Farmer's Field",
                    Description = "There are rows of corn growing here, with giant rats hiding between them.",
                    ImageName = "/images/locations/FarmFields.png"
                },
                new Location
                {
                    XCoordinate = -1,
                    YCoordinate = -1,
                    Name = "Farmer's House",
                    Description = "This is the house of your neighbor, Farmer Ted.",
                    ImageName = "/images/locations/Farmhouse.png",
                    TraderHere = TraderFactory.GetTraderById(102)
                },
                new Location
                {
                    XCoordinate = 0,
                    YCoordinate = -1,
                    Name = "Home",
                    Description = "This is your home.",
                    ImageName = "/images/locations/Home.png"
                },
                new Location
                {
                    XCoordinate = -1,
                    YCoordinate = 0,
                    Name = "Trading Shop",
                    Description = "The shop of Susan, the trader.",
                    ImageName = "/images/locations/Trader.png",
                    TraderHere = TraderFactory.GetTraderById(101)
                },
                new Location
                {
                    XCoordinate = 0,
                    YCoordinate = 0,
                    Name = "Town Square",
                    Description = "You see a fountain here.",
                    ImageName = "/images/locations/TownSquare.png"
                },
                new Location
                {
                    XCoordinate = 1,
                    YCoordinate = 0,
                    Name = "Town Gate",
                    Description = "There is a gate here, protecting the town from giant spiders.",
                    ImageName = "/images/locations/TownGate.png"
                },
                new Location
                {
                    XCoordinate = 2,
                    YCoordinate = 0,
                    Name = "Spider Forest",
                    Description = "The trees in this forest are covered with spider webs.",
                    ImageName = "/images/locations/SpiderForest.png"
                },
                new Location
                {
                    XCoordinate = 0,
                    YCoordinate = 1,
                    Name = "Herbalist's Hut",
                    Description = "You see a small hut, with plants drying from the roof.",
                    ImageName = "/images/locations/HerbalistsHut.png",
                    QuestsAvailableHere = new List<Quest> { QuestFactory.GetQuestById(1) },
                    TraderHere = TraderFactory.GetTraderById(103)
                },
                new Location
                {
                    XCoordinate = 0,
                    YCoordinate = 2,
                    Name = "Herbalist's Garden",
                    Description = "There are many plants here, with snakes hiding behind them.",
                    ImageName = "/images/locations/HerbalistsGarden.png"
                },
            };

            var newWorld = new World(locations);

            // add monsters at their particular location.
            newWorld.LocationAt(-2, -1).AddMonsterEncounter(2, 100);
            newWorld.LocationAt(2, 0).AddMonsterEncounter(3, 100);
            newWorld.LocationAt(0, 2).AddMonsterEncounter(1, 100);

            return newWorld;
        }
    }
}

3. We need to track quests that the player has been given and completed. To enable this, we need to create a new class that manages the completion state of the quest. The Quest always starts as not complete, and can then be changed to the completed state. We will create the QuestStatus class in the SimpleRPG.Game.Engine project and Models folder.

namespace SimpleRPG.Game.Engine.Models
{
    public class QuestStatus
    {
        public QuestStatus(Quest quest)
        {
            PlayerQuest = quest;
        }

        public Quest PlayerQuest { get; set; }

        public bool IsCompleted { get; set; } = false;
    }
}

4. Allow the Player class to have a list of QuestStatus objects. This keeps track of the quests that the player has found and the status of each.

using System.Collections.Generic;

namespace SimpleRPG.Game.Engine.Models
{
    public class Player : LivingEntity
    {
        public string CharacterClass { get; set; } = string.Empty;

        public int ExperiencePoints { get; set; }

        public IList<QuestStatus> Quests { get; set; } = new List<QuestStatus>();
    }
}

Displaying Quests in PlayerTabs

We need to bring all of these changes together in the GameSession view model by finding quests at a location when the player moves through the game world.

using SimpleRPG.Game.Engine.Factories;
using SimpleRPG.Game.Engine.Models;
using SimpleRPG.Game.Engine.Services;
using System;
using System.Collections.Generic;
using System.Linq;

namespace SimpleRPG.Game.Engine.ViewModels
{
    public class GameSession : IGameSession
    {
        private readonly World _currentWorld;
        private readonly IDiceService _diceService;
        private readonly int _maximumMessagesCount = 100;

        public Player CurrentPlayer { get; private set; }

        public Location CurrentLocation { get; private set; }

        public Monster? CurrentMonster { get; private set; }

        public bool HasMonster => CurrentMonster != null;

        public Trader? CurrentTrader { get; private set; }

        public MovementUnit Movement { get; private set; }

        public IList<DisplayMessage> Messages { get; } = new List<DisplayMessage>();

        public GameSession(int maxMessageCount)
            : this()
        {
            _maximumMessagesCount = maxMessageCount;
        }

        public GameSession(IDiceService? diceService = null)
        {
            _diceService = diceService ?? DiceService.Instance;

            CurrentPlayer = new Player
            {
                Name = "DarthPedro",
                CharacterClass = "Fighter",
                CurrentHitPoints = 10,
                MaximumHitPoints = 10,
                Gold = 1000,
                ExperiencePoints = 0,
                Level = 1
            };

            _currentWorld = WorldFactory.CreateWorld();

            Movement = new MovementUnit(_currentWorld);
            CurrentLocation = Movement.CurrentLocation;
            GetMonsterAtCurrentLocation();

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

        public void OnLocationChanged(Location newLocation)
        {
            _ = newLocation ?? throw new ArgumentNullException(nameof(newLocation));

            CurrentLocation = newLocation;
            Movement.UpdateLocation(CurrentLocation);
            GetMonsterAtCurrentLocation();
            GetQuestsAtLocation();
            CurrentTrader = CurrentLocation.TraderHere;
        }

        public void AttackCurrentMonster(Weapon? currentWeapon)
        {
            if (CurrentMonster is null)
            {
                return;
            }

            if (currentWeapon is null)
            {
                AddDisplayMessage("Combat Warning", "You must select a weapon, to attack.");
                return;
            }

            // Determine damage to monster
            int damageToMonster = _diceService.Roll(currentWeapon.DamageRoll).Value;

            if (damageToMonster == 0)
            {
                AddDisplayMessage("Player Combat", $"You missed the {CurrentMonster.Name}.");
            }
            else
            {
                CurrentMonster.CurrentHitPoints -= damageToMonster;
                AddDisplayMessage("Player Combat", $"You hit the {CurrentMonster.Name} for {damageToMonster} points.");
            }

            // If monster if killed, collect rewards and loot
            if (CurrentMonster.CurrentHitPoints <= 0)
            {
                var messageLines = new List<string>();
                messageLines.Add($"You defeated the {CurrentMonster.Name}!");

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

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

                foreach (GameItem item in CurrentMonster.Inventory.Items)
                {
                    CurrentPlayer.Inventory.AddItem(item);
                    messageLines.Add($"You received {item.Name}.");
                }

                AddDisplayMessage("Monster Defeated", messageLines);

                // Get another monster to fight
                GetMonsterAtCurrentLocation();
            }
            else
            {
                // If monster is still alive, let the monster attack
                int damageToPlayer = _diceService.Roll(CurrentMonster.DamageRoll).Value;

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

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

                    CurrentPlayer.CurrentHitPoints = CurrentPlayer.MaximumHitPoints; // Completely heal the player
                    this.OnLocationChanged(_currentWorld.LocationAt(0, -1)); // Return to Player's home
                }
            }
        }

        private void GetMonsterAtCurrentLocation()
        {
            CurrentMonster = CurrentLocation.HasMonster() ? CurrentLocation.GetMonster() : null;

            if (CurrentMonster != null)
            {
                AddDisplayMessage("Monster Encountered:", $"You see a {CurrentMonster.Name} here!");
            }
        }

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

                    var messageLines = new List<string>
                    {
                        quest.Description,
                        "Items to complete the quest:"
                    };

                    foreach (ItemQuantity q in quest.ItemsToComplete)
                    {
                        messageLines.Add($"{ItemFactory.CreateGameItem(q.ItemId).Name} (x{q.Quantity})");
                    }

                    AddDisplayMessage($"Quest Added - {quest.Name}", messageLines);
                }
            }
        }

        private void AddDisplayMessage(string title, string message) =>
            AddDisplayMessage(title, new List<string> { message });

        private void AddDisplayMessage(string title, IList<string> messages)
        {
            var message = new DisplayMessage(title, messages);
            this.Messages.Insert(0, message);

            if (Messages.Count > _maximumMessagesCount)
            {
                Messages.Remove(Messages.Last());
            }
        }
    }
}

During the OnLocationChanged method, we add a check to GetQuestsAtLocation (just like we did with monsters and traders).

The GetQuestsAtLocation method then does the following:

  • Checks whether the player already has the quests at this location. If the player, was already given the quests, then nothing happens.
  • If these are new quests, then:
    • creates a new QuestStatus for it, and adds it to the Player.Quests list.
    • sets up a DisplayMessage with the Quest details for the player to see.

With the view model updated, we just need to change the PlayersTab component to show the Quests list. This component starts with an empty Quests tab, so we need to add a <Table> to display data about the player’s quests. We will do this by updating the component with the following code:

<Tabs SelectedTab="@_selectedTab" Pills="true" SelectedTabChanged="OnSelectedTabChanged">
    <Items>
        <Tab Name="inventory">Inventory</Tab>
        <Tab Name="quests">Quests</Tab>
    </Items>
    <Content>
        <TabPanel Name="inventory">
            <div class="table-wrapper-scroll-y my-custom-scrollbar">
                <Table Bordered="true" Hoverable="true" Narrow="true" Striped="true"
                       Style="background-color: white">
                    <TableHeader>
                        <TableRowCell>Name</TableRowCell>
                        <TableRowCell>Qty</TableRowCell>
                        <TableRowCell>Price</TableRowCell>
                    </TableHeader>
                    <TableBody>
                        @foreach (var groupedItem in Player.Inventory.GroupedItems)
                        {
                        <TableRow>
                            <TableRowCell>@groupedItem.Item.Name</TableRowCell>
                            <TableRowCell>@groupedItem.Quantity</TableRowCell>
                            <TableRowCell>@groupedItem.Item.Price</TableRowCell>
                        </TableRow>
                        }
                    </TableBody>
                </Table>
            </div>
        </TabPanel>
        <TabPanel Name="quests">
            <div class="table-wrapper-scroll-y my-custom-scrollbar">
                <Table Bordered="true" Hoverable="true" Narrow="true" Striped="true"
                       Style="background-color: white">
                    <TableHeader>
                        <TableRowCell>Name</TableRowCell>
                        <TableRowCell>Done?</TableRowCell>
                    </TableHeader>
                    <TableBody>
                        @foreach (var quest in Player.Quests)
                        {
                        <TableRow>
                            <TableRowCell>@quest.PlayerQuest.Name</TableRowCell>
                            <TableRowCell>@(quest.IsCompleted ? "Yes" : "No")</TableRowCell>
                        </TableRow>
                        }
                    </TableBody>
                </Table>
            </div>
        </TabPanel>
    </Content>
</Tabs>

@code {
    private string _selectedTab = "inventory";

    [Parameter]
    public Player Player { get; set; } = new Player();

    public void OnSelectedTabChanged(string newTab)
    {
        _selectedTab = newTab;
    }
}

The new table is added in lines #30-47. It is similar in structure to the table in the Inventory tab, but with different column names and data-bound to the Player.Quests property.

  • The markup for the <Table> and scrolling area are exactly the same.
  • The table has a column for the Quest.Name and another column for its completion state.
  • Then for each Quest, we create a row with the name and state for that instance.
  • Finally, note the translation code on line #42 to format the display text as Yes/No rather than showing the default true/false value of the boolean data type. That will be easier for the player to understand than the boolean terms.

With all of this code in place, we can again build and run the game. When we enter the Herbalist’s hut for the first time (if we come back to the same location, nothing else is added), we will see the following display message:

Figure 1 – Finding a quest

The player was given a quest to go retrieve 5 snake fangs for the herbalist, and so our adventure begins! Next lesson, we will go hunt down some snakes to retrieve the fangs, and then focus on the logic to complete the quest.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s