Our game now has a player with inventory, game items, and the ability to move between locations. But, no game is complete without some antagonists for our hero to fight. We are going to introduce monsters into the game world for the player to battle.
Creating the Monster Class
Let’s start with a new Monster
class in the SimpleRPG.Game.Engine and Models folder:
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
namespace SimpleRPG.Game.Engine.Models
{
public class Monster
{
public string Name { get; set; } = string.Empty;
public string ImageName { get; set; } = string.Empty;
public int MaximumHitPoints { get; set; }
public int HitPoints { get; set; }
public int RewardExperiencePoints { get; set; }
public int RewardGold { get; set; }
public IList<GameItem> Inventory { get; } = new List<GameItem>();
}
}
The Monster
class should look pretty similar… it has a name, some hit point information, gold, and inventory. These properties are very much like the Player
class we already have. Then it adds properties for: an image (to show in the game screen later), experience points that are rewarded for killing this monster, and the maximum hit points the monster can have. We also know that we want to add more types of creatures, like non-player characters (traders), in the future, so this is a good opportunity to build a class hierarchy with specific derived creature types that inherit from a base LivingEntity
class (recall we discussed class inheritance in Lesson 3.1).
Let’s create a LivingEnity
class in the SimpleRPG.Game.Engine and Models folder. We will move the common properties into this base class.
namespace SimpleRPG.Game.Engine.Models
{
public abstract class LivingEntity
{
public string Name { get; set; } = string.Empty;
public int CurrentHitPoints { get; set; }
public int MaximumHitPoints { get; set; }
public int Gold { get; set; }
public int Level { get; set; }
public Inventory Inventory { get; } = new Inventory();
}
}
Notice that in line 3, we use the abstract
modifier on this class. Using the abstract
modifier in a class declaration indicates that the class is intended only to be a base class of other classes, not instantiated on its own.
We can now modify the Monster
class to derive from LivingEntity
and only provide the additional properties that are not in the base class (that’s the ImageName
and RewardExperiencePoints
).
namespace SimpleRPG.Game.Engine.Models
{
public class Monster : LivingEntity
{
public string ImageName { get; set; } = string.Empty;
public int RewardExperiencePoints { get; set; }
}
}
Notice that we changed some of the original Monster
property names so that they can be shared with other living entities.
Next, we need to update the Player
class in the same way:
namespace SimpleRPG.Game.Engine.Models
{
public class Player : LivingEntity
{
public string CharacterClass { get; set; } = string.Empty;
public int ExperiencePoints { get; set; }
}
}
The Player
defines its CharacterClass
property and its own ExperiencePoints
property. This property accumulates experience for the player and is not similar in behavior to the Monster.RewardExperiencePoints
, so we are treating them as different properties as well.
Adding Monster Images
Just like we did with locations in Lesson 2.8, we are going to add monster images as resources to our game project.
First, we need to create the “/wwwroot/images/monsters” folder in our SimpleRPG.Game project. Then, add all of the images from the original SimpleRPG project, or from my code repository. Add them as content files to our project by copying them into the folder. All of the wwwroot folder’s content is copied to our output folder during the build process, so they will be available to our game at run-time.
In our MonsterFactory
code, we will set the Monster.ImageName
property to match the relative path in our source code as:
ImageName = "/images/monsters/snake.png"
Updating ItemFactory with Monster Items
We need to create some more items in the game world, to add to the monster’s inventory – for the player to loot from them. We will add items that represent body parts harvested from these monsters.
Let’s update ItemFactory to add these game items:
using SimpleRPG.Game.Engine.Models;
using System.Collections.Generic;
using System.Linq;
namespace SimpleRPG.Game.Engine.Factories
{
internal static class ItemFactory
{
private static List<GameItem> _standardGameItems = new List<GameItem>
{
new Weapon(1001, "Pointy Stick", 1, 1, 2),
new Weapon(1002, "Rusty Sword", 5, 1, 3),
new GameItem(9001, "Snake fang", 1),
new GameItem(9002, "Snakeskin", 2),
new GameItem(9003, "Rat tail", 1),
new GameItem(9004, "Rat fur", 2),
new GameItem(9005, "Spider fang", 1),
new GameItem(9006, "Spider silk", 2)
};
public static GameItem CreateGameItem(int itemTypeID)
{
var standardItem = _standardGameItems.First(i => i.ItemTypeID == itemTypeID);
return standardItem.Clone();
}
}
}
Creating the Monster Factory
Just like we did with other model class in our game engine, we are going to create the MonsterFactory
to provide instances of monsters based on a monster type id. For now these ids are hard-coded, that’s not the greatest way to code these in our game engine, but in future lessons we are going to be loading this monster data from data files and/or from web services, and we will redesign how these factories work at that point.
We are going to create the MonsterFactory
class in the SimpleRPG.Game.Engine and Factories folder:
using SimpleRPG.Game.Engine.Models;
using SimpleRPG.Game.Engine.Services;
using System;
namespace SimpleRPG.Game.Engine.Factories
{
internal static class MonsterFactory
{
private static readonly IDiceService _service = DiceService.Instance;
public static Monster GetMonster(int monsterID)
{
switch (monsterID)
{
case 1:
Monster snake = new Monster
{
Name = "Snake",
ImageName = "/images/monsters/snake.png",
CurrentHitPoints = 4,
MaximumHitPoints = 4,
RewardExperiencePoints = 5,
Gold = 1
};
AddLootItem(snake, 9001, 25);
AddLootItem(snake, 9002, 75);
return snake;
case 2:
Monster rat = new Monster
{
Name = "Rat",
ImageName = "/images/monsters/rat.png",
CurrentHitPoints = 5,
MaximumHitPoints = 5,
RewardExperiencePoints = 5,
Gold = 1
};
AddLootItem(rat, 9003, 25);
AddLootItem(rat, 9004, 75);
return rat;
case 3:
Monster giantSpider = new Monster
{
Name ="Giant Spider",
ImageName = "/images/monsters/giant-spider.png",
CurrentHitPoints = 10,
MaximumHitPoints = 10,
RewardExperiencePoints = 10,
Gold = 3
};
AddLootItem(giantSpider, 9005, 25);
AddLootItem(giantSpider, 9006, 75);
return giantSpider;
default:
throw new ArgumentOutOfRangeException(nameof(monsterID));
}
}
private static void AddLootItem(Monster monster, int itemID, int percentage)
{
if (_service.Roll("1d100").Value <= percentage)
{
monster.Inventory.AddItem(ItemFactory.CreateGameItem(itemID));
}
}
}
}
The bulk of the code in the GetMonster
method is the switch statement that creates the specified monster based on monsterID
. Each case creates its own Monster
instance with the data for that particular monster. They also call AddLoot
for each monster with a percentage value for how common it is to find that particular loot item — 25% for the first item and 75% for the second item.
Then, the default
branch of the switch statement means that none of the known monster ids were requested, so we throw an ArgumentOutOfRange
exception for ids that are not valid.
Finally, the AddLoot
method takes an item id and a percentage. We use the DiceService
(implemented in our last lesson) to roll a random number between 1 and 100. If the randomly generated number is less than or equal to the specified percentage, then we add that loot item to the Monster.Inventory
. If it’s greater than the percentage, then that item is not added to the Inventory
. This adds some variability to our monsters and makes our game a little more realistic.
Unit Testing Random
Variability is great in our game, but is difficult to deal with in our tests. Trying to test if the MonsterFactory
works correctly when you could have 0, 1, or 2 items in any monster’s inventory can introduce inconsistent results in our test code. We need to bring some predictability into our tests.
Let’s take a look at the MonsterFactoryTests
class to see how we are handling that:
using SimpleRPG.Game.Engine.Factories;
using SimpleRPG.Game.Engine.Services;
using System;
using Xunit;
namespace SimpleRPG.Game.Engine.Tests.Factories
{
public class MonsterFactoryTests
{
public MonsterFactoryTests()
{
DiceService.Instance.Configure(IDiceService.RollerType.Constant, constantValue: 50);
}
[Fact]
public void CreateMonster_Snake()
{
// arrange
// act
var m = MonsterFactory.GetMonster(1);
// assert
Assert.NotNull(m);
Assert.Equal("Snake", m.Name);
Assert.Equal(4, m.CurrentHitPoints);
Assert.Equal(5, m.RewardExperiencePoints);
Assert.Equal(1, m.Gold);
Assert.NotEmpty(m.Inventory.GroupedItems);
Assert.Equal(1, m.Inventory.GroupedItems.Count);
}
[Fact]
public void CreateMonster_Rat()
{
// arrange
// act
var m = MonsterFactory.GetMonster(2);
// assert
Assert.NotNull(m);
Assert.Equal("Rat", m.Name);
Assert.Equal(5, m.CurrentHitPoints);
Assert.Equal(5, m.RewardExperiencePoints);
Assert.Equal(1, m.Gold);
Assert.NotEmpty(m.Inventory.GroupedItems);
Assert.Equal(1, m.Inventory.GroupedItems.Count);
}
[Fact]
public void CreateMonster_GiantSpider()
{
// arrange
// act
var m = MonsterFactory.GetMonster(3);
// assert
Assert.NotNull(m);
Assert.Equal("Giant Spider", m.Name);
Assert.Equal(10, m.CurrentHitPoints);
Assert.Equal(10, m.RewardExperiencePoints);
Assert.Equal(3, m.Gold);
Assert.NotEmpty(m.Inventory.GroupedItems);
Assert.Equal(1, m.Inventory.GroupedItems.Count);
}
[Fact]
public void CreateMonster_InvalidId()
{
// arrange
// act
Assert.Throws<ArgumentOutOfRangeException>(() => MonsterFactory.GetMonster(101));
// assert
}
}
}
In the test constructor (line 12), we configure the IDiceService
to use a constant die roller type with a constant value of 50. From this point forward in our test, whenever we call IDiceService.Roll
, it will use the constant die roller and always return 50. This allows us to verify our AddLoot
code because it will always add exactly one loot item to the monster’s inventory. And when this same code is executed in production, it will use the random die roller and give us the creature variability.
We can build the source code and run all of the tests to ensure everything is passing as expected. If we run the game again, there isn’t any visible change just yet.
We are now able to create monsters as much as we would like. In the next lesson, we are going to look at adding monsters to particular locations in the game world and showing them in the game screen.