Another circular dependency we need to remove is with Engine\DiceService.
This service is currently used by another service and some models. If we move it to SOSCSRPG.Services, the classes we’ll eventually move to SOSCSRPG.Models won’t be able to use it.
We could solve this with dependency injection – passing in a DiceService object to each class that needs it, but I want to solve this a different way.
We’re going to create an SOSCSRPG.Core class library project to hold things that are used by multiple projects, and are more like extensions to the C# language, not things specific to our program.
Step 1: Add new SOSCSRPG.Core class library project to the solution
Step 2: Add NuGet packages to SOSCSRPG.Core
Add the latest version of these NuGet packages to SOSCSRPG.Core
- D20Tek.Common.Standard
- D20.Tek.DiceNotation.Standard
Step 3: Add references to SOSCSRPG.Core
Add these project dependency references:
- Engine to SOSCSRPG.Core
- SOSCSRPG.Models to SOSCSRPG.Core
- SOSCSRPG.Services to SOSCSRPG.Core
Step 4: Move DiceService class and interface to SOSCSRPG.Core
Cut-and-paste Services\IDiceService.cs and Services\DiceService.cs from the Engine project to SOSCSRPG.Core
Step 5: Update namespaces in DiceService class and interface
Change the namespace from Engine.Services to SOSCSRPG.Core.
In DiceService.cs, change the private static class-level variable from “_instance” to “s_instance”, to fit the coding standards.
I also fixed a type on line 12 “singletone” to “singleton”
IDiceService.cs
using D20Tek.DiceNotation;
using D20Tek.DiceNotation.DieRoller;
namespace SOSCSRPG.Core
{
public interface IDiceService
{
IDice Dice { get; }
IDiceConfiguration Configuration { get; }
IDieRollTracker RollTracker { get; }
void Configure(RollerType rollerType, bool enableTracker = false, int constantValue = 1);
DiceResult Roll(string diceNotation);
DiceResult Roll(int sides, int numDice = 1, int modifier = 0);
}
public enum RollerType
{
Random = 0,
Crypto = 1,
MathNet = 2,
Constant = 3
}
}
DiceService.cs
using System;
using D20Tek.DiceNotation;
using D20Tek.DiceNotation.DieRoller;
namespace SOSCSRPG.Core
{
public class DiceService : IDiceService
{
private static readonly IDiceService s_instance = new DiceService();
/// <summary>
/// Make constructor private to implement singleton pattern.
/// </summary>
private DiceService()
{
}
/// <summary>
/// Static singleton property
/// </summary>
public static IDiceService Instance => s_instance;
//--- IDiceService implementation
public IDice Dice { get; } = new Dice();
public IDieRoller DieRoller { get; private set; } = new RandomDieRoller();
public IDiceConfiguration Configuration => Dice.Configuration;
public IDieRollTracker RollTracker { get; private set; } = null;
public void Configure(RollerType rollerType, bool enableTracker = false, int constantValue = 1)
{
RollTracker = enableTracker ? new DieRollTracker() : null;
switch (rollerType)
{
case RollerType.Random:
DieRoller = new RandomDieRoller(RollTracker);
break;
case RollerType.Crypto:
DieRoller = new CryptoDieRoller(RollTracker);
break;
case RollerType.MathNet:
DieRoller = new MathNetDieRoller(RollTracker);
break;
case RollerType.Constant:
DieRoller = new ConstantDieRoller(constantValue);
break;
default:
throw new ArgumentOutOfRangeException(nameof(rollerType));
}
}
public DiceResult Roll(string diceNotation) => Dice.Roll(diceNotation, DieRoller);
public DiceResult Roll(int sides, int numDice = 1, int modifier = 0)
{
DiceResult result = Dice.Dice(sides, numDice).Constant(modifier).Roll(DieRoller);
Dice.Clear();
return result;
}
}
}
Step 4: Update namespace in calling classes
Add “using SOSCSPRG.Core;” to the classes below that use the DiceService class.
Engine\Actions\AttackWithWeapon.cs
using System;
using Engine.Models;
using Engine.Services;
using SOSCSRPG.Core;
namespace Engine.Actions
{
public class AttackWithWeapon : BaseAction, IAction
{
private readonly string _damageDice;
public AttackWithWeapon(GameItem itemInUse, string damageDice)
: base(itemInUse)
{
if (itemInUse.Category != GameItem.ItemCategory.Weapon)
{
throw new ArgumentException($"{itemInUse.Name} is not a weapon");
}
if (string.IsNullOrWhiteSpace(damageDice))
{
throw new ArgumentException("damageDice must be valid dice notation");
}
_damageDice = damageDice;
}
public void Execute(LivingEntity actor, LivingEntity target)
{
string actorName = (actor is Player) ? "You" : $"The {actor.Name.ToLower()}";
string targetName = (target is Player) ? "you" : $"the {target.Name.ToLower()}";
if(CombatService.AttackSucceeded(actor, target))
{
int damage = DiceService.Instance.Roll(_damageDice).Value;
ReportResult($"{actorName} hit {targetName} for {damage} point{(damage > 1 ? "s" : "")}.");
target.TakeDamage(damage);
}
else
{
ReportResult($"{actorName} missed {targetName}.");
}
}
}
}
Engine\Models\Location.cs
using System.Collections.Generic;
using System.Linq;
using Engine.Factories;
using Newtonsoft.Json;
using SOSCSRPG.Core;
namespace Engine.Models
{
public class Location
{
public int XCoordinate { get; }
public int YCoordinate { get; }
[JsonIgnore]
public string Name { get; }
[JsonIgnore]
public string Description { get; }
[JsonIgnore]
public string ImageName { get; }
[JsonIgnore]
public List<Quest> QuestsAvailableHere { get; } = new List<Quest>();
[JsonIgnore]
public List<MonsterEncounter> MonstersHere { get; } =
new List<MonsterEncounter>();
[JsonIgnore]
public Trader TraderHere { get; set; }
public Location(int xCoordinate, int yCoordinate, string name, string description, string imageName)
{
XCoordinate = xCoordinate;
YCoordinate = yCoordinate;
Name = name;
Description = description;
ImageName = imageName;
}
public void AddMonster(int monsterID, int chanceOfEncountering)
{
if(MonstersHere.Exists(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 Monster GetMonster()
{
if(!MonstersHere.Any())
{
return null;
}
// 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).
int randomNumber = DiceService.Instance.Roll(totalChances, 1).Value;
// 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(randomNumber <= runningTotal)
{
return MonsterFactory.GetMonster(monsterEncounter.MonsterID);
}
}
// If there was a problem, return the last monster in the list.
return MonsterFactory.GetMonster(MonstersHere.Last().MonsterID);
}
}
}
Engine\Models\Monster.cs
using System.Collections.Generic;
using Engine.Factories;
using SOSCSRPG.Core;
namespace Engine.Models
{
public class Monster : LivingEntity
{
private readonly List<ItemPercentage> _lootTable =
new List<ItemPercentage>();
public int ID { get; }
public string ImageName { get; }
public int RewardExperiencePoints { get; }
public Monster(int id, string name, string imageName,
int maximumHitPoints, IEnumerable<PlayerAttribute> attributes,
GameItem currentWeapon,
int rewardExperiencePoints, int gold) :
base(name, maximumHitPoints, maximumHitPoints, attributes, gold)
{
ID = id;
ImageName = imageName;
CurrentWeapon = currentWeapon;
RewardExperiencePoints = rewardExperiencePoints;
}
public void AddItemToLootTable(int id, int percentage)
{
// Remove the entry from the loot table,
// if it already contains an entry with this ID
_lootTable.RemoveAll(ip => ip.ID == id);
_lootTable.Add(new ItemPercentage(id, percentage));
}
public Monster GetNewInstance()
{
// "Clone" this monster to a new Monster object
Monster newMonster =
new Monster(ID, Name, ImageName, MaximumHitPoints, Attributes,
CurrentWeapon, RewardExperiencePoints, Gold);
foreach(ItemPercentage itemPercentage in _lootTable)
{
// Clone the loot table - even though we probably won't need it
newMonster.AddItemToLootTable(itemPercentage.ID, itemPercentage.Percentage);
// Populate the new monster's inventory, using the loot table
if(DiceService.Instance.Roll(100).Value <= itemPercentage.Percentage)
{
newMonster.AddItemToInventory(ItemFactory.CreateGameItem(itemPercentage.ID));
}
}
return newMonster;
}
}
}
Engine\Models\PlayerAttribute.cs
using System.ComponentModel;
using SOSCSRPG.Core;
namespace Engine.Models
{
public class PlayerAttribute : INotifyPropertyChanged
{
public string Key { get; }
public string DisplayName { get; }
public string DiceNotation { get; }
public int BaseValue { get; set; }
public int ModifiedValue { get; set; }
public event PropertyChangedEventHandler PropertyChanged;
// Constructor that will use DiceService to create a BaseValue.
// The constructor this calls will put that same value into BaseValue and ModifiedValue
public PlayerAttribute(string key, string displayName, string diceNotation)
: this(key, displayName, diceNotation, DiceService.Instance.Roll(diceNotation).Value)
{
}
// Constructor that takes a baseValue and also uses it for modifiedValue,
// for when we're creating a new attribute
public PlayerAttribute(string key, string displayName, string diceNotation,
int baseValue) :
this(key, displayName, diceNotation, baseValue, baseValue)
{
}
// This constructor is eventually called by the others,
// or used when reading a Player's attributes from a saved game file.
public PlayerAttribute(string key, string displayName, string diceNotation,
int baseValue, int modifiedValue)
{
Key = key;
DisplayName = displayName;
DiceNotation = diceNotation;
BaseValue = baseValue;
ModifiedValue = modifiedValue;
}
public void ReRoll()
{
BaseValue = DiceService.Instance.Roll(DiceNotation).Value;
ModifiedValue = BaseValue;
}
}
}
Engine\Services\CombatService.cs
using Engine.Models;
using Engine.Shared;
using SOSCSRPG.Core;
namespace Engine.Services
{
public static class CombatService
{
public enum Combatant
{
Player,
Opponent
}
public static Combatant FirstAttacker(Player player, Monster opponent)
{
// Formula is: ((Dex(player)^2 - Dex(monster)^2)/10) + Random(-10/10)
// For dexterity values from 3 to 18, this should produce an offset of +/- 41.5
int playerDexterity = player.GetAttribute("DEX").ModifiedValue *
player.GetAttribute("DEX").ModifiedValue;
int opponentDexterity = opponent.GetAttribute("DEX").ModifiedValue *
opponent.GetAttribute("DEX").ModifiedValue;
decimal dexterityOffset = (playerDexterity - opponentDexterity) / 10m;
int randomOffset = DiceService.Instance.Roll(20).Value - 10;
decimal totalOffset = dexterityOffset + randomOffset;
return DiceService.Instance.Roll(100).Value <= 50 + totalOffset
? Combatant.Player
: Combatant.Opponent;
}
public static bool AttackSucceeded(LivingEntity attacker, LivingEntity target)
{
// Currently using the same formula as FirstAttacker initiative.
// This will change as we include attack/defense skills,
// armor, weapon bonuses, enchantments/curses, etc.
int playerDexterity = attacker.GetAttribute("DEX").ModifiedValue *
attacker.GetAttribute("DEX").ModifiedValue;
int opponentDexterity = target.GetAttribute("DEX").ModifiedValue *
target.GetAttribute("DEX").ModifiedValue;
decimal dexterityOffset = (playerDexterity - opponentDexterity) / 10m;
int randomOffset = DiceService.Instance.Roll(20).Value - 10;
decimal totalOffset = dexterityOffset + randomOffset;
return DiceService.Instance.Roll(100).Value <= 50 + totalOffset;
}
}
}
Step 6: Test the game
NEXT LESSON: Lesson 19.9: Decouple services from Location and Monster model classes
PREVIOUS LESSON: Lesson 19.7: Decouple services from ItemQuantity