Lesson 3.5: Creating Monsters

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.

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 )

Facebook photo

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

Connecting to %s