As we go around the village ridding it of monsters, we are starting to accumulate items (snakeskins, fangs, and rat tails). We need to do something with these before they start to clutter up our inventory… not to mention smelling up our backpack. Let’s add traders to the game so that we can sell these items and start making a living.
Traders are non-player characters (NPCs) that are found around game world locations that will buy and sell us items. The traders will provide us the Item.Price
, if we sell it to them. And they will also charge us the Item.Price
to buy their wares (at least for the time being).
Place Traders at Locations
1. We will define a Trader
model class that derives from LivingBeing
. The trader is another creature, so deriving from the base class makes sense. The power of inheritance is that Trader
gets useful functionality like an Inventory
from the base LivingBeing
class. Let’s create the Trader
class in the SimpleRPG.Game.Engine project and Models folder.
namespace SimpleRPG.Game.Engine.Models
{
public class Trader : LivingEntity
{
public int Id { get; set; }
}
}
2. Let’s create a TraderFactory
(similar to the other factories in the game) that return instances of Trader
objects based on the trader’s Id
. In the static constructor, we create the three traders in our village. The Id
is the key used in the List.First
search method.
using SimpleRPG.Game.Engine.Models;
using System.Collections.Generic;
using System.Linq;
namespace SimpleRPG.Game.Engine.Factories
{
internal static class TraderFactory
{
private static readonly List<Trader> _traders = new List<Trader>();
static TraderFactory()
{
_traders.Add(CreateTrader(101, "Susan"));
_traders.Add(CreateTrader(102, "Farmer Ted"));
_traders.Add(CreateTrader(103, "Pete the Herbalist"));
}
public static Trader GetTraderById(int id) => _traders.First(t => t.Id == id);
private static Trader CreateTrader(int id, string name)
{
Trader t = new Trader
{
Id = id,
Name = name,
Level = 0,
Gold = 100,
MaximumHitPoints = 999,
CurrentHitPoints = 999
};
t.Inventory.AddItem(ItemFactory.CreateGameItem(1001));
return t;
}
}
}
3. Update the Location class to have properties for TraderHere
and HasTrader
, that are similar in concept and behavior to MonstersHere
and HasMonster
.
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<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);
}
}
}
4. Update WorldFactory
to place traders at particular locations. We will place 3 traders at the farmer’s house, trading shop, and 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",
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;
}
}
}
5. Update the GameSession
to surface the CurrentTrader
when one is available – when the player moves into a location that has a TraderHere
defined.
using SimpleRPG.Game.Engine.Models;
using System.Collections.Generic;
namespace SimpleRPG.Game.Engine.ViewModels
{
public interface IGameSession
{
Player CurrentPlayer { get; }
Location CurrentLocation { get; }
Monster? CurrentMonster { get; }
bool HasMonster { get; }
Trader? CurrentTrader { get; }
MovementUnit Movement { get; }
IList<DisplayMessage> Messages { get; }
void OnLocationChanged(Location newLocation);
void AttackCurrentMonster(Weapon? currentWeapon);
}
}
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();
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 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());
}
}
}
}
We had to make several changes across our different engine classes, but the amount of code change wasn’t very much. We added the trader functionality to the game engine pretty easily, which helps confirm that our code design is continuing to support our features.
Create TraderViewModel
Since we are building a new UI component for the trader screen, we will also build a view model to encapsulate the functionality of that component. The view model will need to provide player and trader inventories to be shown on screen. And, it will need to handle operations for buying and selling game items between the two.
using Microsoft.AspNetCore.Components;
using SimpleRPG.Game.Engine.Models;
using System;
namespace SimpleRPG.Game.Engine.ViewModels
{
public class TraderViewModel
{
public Trader? Trader { get; set; } = null;
public Player? Player { get; set; } = null;
public string ErrorMessage { get; private set; } = string.Empty;
public EventCallback InventoryChanged { get; set; }
public void OnSellItem(GameItem item)
{
_ = item ?? throw new ArgumentNullException(nameof(item));
if (Player != null && Trader != null)
{
Player.Gold += item.Price;
Trader.Inventory.AddItem(item);
Player.Inventory.RemoveItem(item);
InventoryChanged.InvokeAsync(null);
}
}
public void OnBuyItem(GameItem item)
{
_ = item ?? throw new ArgumentNullException(nameof(item));
if (Player != null && Trader != null)
{
ErrorMessage = string.Empty;
if (Player.Gold >= item.Price)
{
Player.Gold -= item.Price;
Trader.Inventory.RemoveItem(item);
Player.Inventory.AddItem(item);
InventoryChanged.InvokeAsync(null);
}
else
{
ErrorMessage = "Error: you do not have enough gold.";
}
}
}
}
}
Let’s take a detailed look at the TraderViewModel
implementation:
- We define
Player
andTrader
properties to get access to their inventories and perform other operations. - We define an
ErrorMessage
property because we will need to show an error on the UI in certain situations… like if the player doesn’t have enough money to purchase the selected item. - We define an
EventCallback
to notify the main screen when the player’s inventory and gold has changed. This allows the main screen to update in response to that event. - The
OnSellItem
method:- starts with some checks of the
item
,Player
, andTrader
. The action is not allowed if any of those are null. - gives the player the gold value of the item.
- removes the item from the player’s inventory.
- then adds the item to the trader’s inventory.
- and invokes the
InventoryChanged
event callback.
- starts with some checks of the
- The OnBuyItem method:
- also checks that the
item
,Player
, andTrader
elements are not null.
- takes the price of the item from the player’s gold.
- removes the item from the trader’s inventory.
- adds the item to the player’s inventory.
- and invokes the
InventoryChanged
event callback. - if the player did not have enough Gold to buy the item, an ErrorMessage is given instead.
- also checks that the
We use the Inventory
class and its AddItem
and RemoveItem
methods to do a lot of the heavy lifting in moving items between inventory, so we didn’t have to rebuild that logic here. And the code here only focuses on the buy and sell logic.
With the TraderViewModel
complete, we can build the code and run all of the tests. If we run the game now, we will not see any changes in the game screen yet, those come in next lesson.
One thought on “Lesson 3.9: Adding Traders to the Game Engine”