** Originally published on the 31st of July 2018 **
*** This article is the 2nd part of a series. You can find the first part here ***
It's been a while since the first part of this series was published in this blog. I'll be posting the rest of the articles soon, hopefully keeping to the pace of an article a month, as I need to rewrite a lot of the original posts (and code) that were previously published elsewhere (and since removed). At the end I also hope to show how I "productize" the algorithm and publish it on the Unity Asset Store. Some parts (the ones that have to do with Unity Editor integration) have been removed from the code examples.
Those of you following this series of posts may remember that last time I showed a general algorithm for procedural generating levels for my 2D MMORPG. As you may remember, I've written down a list of general, empty functions that we'll try to create as we move on through the series. But first - let's explain our algorithm a little bit, and for that - let's talk about the game we're working on.
The game - The Tower (working title) - is a 2D MMORPG. If you don't know what that means, think about your normal 3D MMORPG, but with a side view. Think World of Warcraft meets Mario.
In this game, you'll be playing through instances (dungeons that are created for you). A lot of them. And I want those dungeons to be procedurally generated, so that even if you do the same instance twice - it'll still feel different.
So, in it's most basic form, every time somebody enters an instance I call the function GenerateInstance() and I have an instance. But GenerateInstance() can't really be parameter-less, as a lot of information needs to be set right on the start. Or does it??
Here's the thing: I want the function GenerateInstance() to be a general, all encompassing function. Sure, my game will only use a small subset of its features, but that subset may change in the future, and I want the function to be able to accommodate.
In order to accommodate every use of its functionality, the GenerateInstance() function will have to use some creative thinking. Or, in simpler terms: it has to set default values, or randomize them, thus allowing the user to choose which values he wants to type in, and which he doesn't care about and lets the function choose.
Size matters
We will define 4 sizes for a game level: small, medium, large and extra-large (XL). A user may choose to create an instance with each of those as its basic size. However, if a user doesn't choose a size, a random size will be chosen for him from small, medium and large. Basically it means that if you want an extra-large level - you have to be specific about it.
2D games are generally tile based, or in other words - grid based. The sizes we choose define the size of the grid we'll create. So, let's discuss what the sizes actually mean.
Small: A small instance means just a few rooms, easily connected. Maybe 2 to 4 rooms, but not a lot more. Given that room sizes can also change, we're looking at room sized around 3x3 grid squares. Doing some quick math (and adding some safe margins), and we get that the actual grid is somewhere around 6 to 15 squares. Let's play it safe and set the grid to be up to a maximum of 20x20.
Medium: A medium instance is an instance that has more than a few rooms. It probably has several branching corridors, and dead-ends. It'll probably have up to 1 or 2 boss fights, and no special events. Room sizes are slightly bigger as well, and so are the actual internals: items, decorations, monsters, etc. We're assuming anywhere between 5 and 15 rooms here, each sized at up to 10x10. Again, quick math, and we get somewhere around 150x150. Let's set the maximum of the grid to be 200x200.
Large: A large instance is an instance that has a lot of rooms, many branching corridors and dead-ends. It might contain more than 1 boss fight, and several special events. Again, room sizes can get bigger, up to 25x25 in size, and we can probably have up to 50 of them. As you can see, this size introduces a major increase in instance size. After doing some math, we get to a grid size of 1500x1500.
XL: Extra large dungeons are a variation of the large dungeon. The room size doesn't change by much (let's set it to 30x30), but the actual number of rooms doubles - up to a 100. We're looking at a grid size of around 4000x4000.
This decision to have 4 basic sizes of instances may seem limiting at first, especially since we don't exactly know what "small" means here. However, users of the script will be able to extend the class, as well as redefine what the sizes actually mean for their game. I'll supply default values that work for my game, but game developers will have to see what works for them. Instructions on how to extend or define new sizes will be added to the comments of each function (some comments have been removed from the blog post).
Let's define a class to hold the definitions of a single size:
public class SizeDefinition
{
public string Name { get; private set; }
public int MaxHeight { get; private set; }
public int MaxWidth { get; private set; }
public int MinHeight { get; private set; }
public int MinWidth { get; private set; }
public bool AvailableForUnknown { get; private set; }
public SizeDefinition(string name, int maxHeight, int maxWidth, bool availableForUnknown, int minHeight = 1, int minWidth = 1)
{
this.Name = name;
this.MaxHeight = maxHeight;
this.MaxWidth = maxWidth;
this.MinHeight = minHeight;
this.MinWidth = minWidth;
this.AvailableForUnknown = availableForUnknown;
}
}
Seems simple enough, right? Let's discuss some of those fields:
Name - the name of the size (Small, Medium, etc). Can also be named LongHorizonal, if we choose to do so.
MaxHeight and MaxWidth - sets the max height and max width respectably, of the instance size.
MinHeight and MinWidth - sets the minimal height and minimal width respectably. If no values are given, sets both to 1.
AvailableForUnknown - if the user chooses "Unknown" as his size, can I use this size definition as a random value? For example, in our examples from above, this would be set to true for Small, Medium and Large, but set to false for XL, as we don't want users to create XL instances unless they plan it in advance.
Now, let's create a list to hold those values, and set them in the constructor:
public static class SizeDefinitions
{
public static Dictionary DefinitionsDictionary { get; private set; }
static SizeDefinitions()
{
// Creates all of the custom definitions
SizeDefinition small = new SizeDefinition("Small", 20, 20, true);
SizeDefinition medium = new SizeDefinition("Medium", 200, 200, true);
SizeDefinition large = new SizeDefinition("Large", 1500, 1500, true);
SizeDefinition xl = new SizeDefinition("XL", 4000, 4000, false, 2000, 2000);
DefinitionsDictionary.Add("Small", small);
DefinitionsDictionary.Add("Medium", medium);
DefinitionsDictionary.Add("Large", large);
DefinitionsDictionary.Add("XL", xl);
}
public static List GetListOfAvailableSizes()
{
List result = new List();
foreach (var item in DefinitionsDictionary)
{
if (item.Value.AvailableForUnknown)
result.Add(item.Value.Name);
}
return result;
}
}
You'll notice that when setting up the various sizes, we set AvailableForUnknown to true on the first 3 sizes, and to false on XL. We also left the first minimum sizes at their default values (1), and set the minimum sizes of all the rest to some interesting values.
You may also notice the existence GetListOfAvailableSizes() function. We'll discuss that a bit later.
At this point, writing the InstanceSize class is pretty simple:
public class InstanceSize
{
public int Height { get; private set; }
public int Width { get; private set; }
public InstanceSize(int minHeight, int minWidth, int maxHeight, int maxWidth)
{
Random rnd = new Random();
this.Height = rnd.Next(minHeight, maxHeight);
this.Width = rnd.Next(minWidth, maxWidth);
}
}
The only remaining part is the most important one: the ProcGenInstance class. The class that actually does all the work for us. Let's write it, with the GenerateSize() function.
public class ProcGenInstance
{
public InstanceSize Size { get; private set; }
public void GenerateSize(string requestedSize)
{
if (SizeDefinitions.DefinitionsDictionary.ContainsKey(requestedSize))
{
SizeDefinition sizeDef = SizeDefinitions.DefinitionsDictionary[requestedSize]; this.Size = new InstanceSize(sizeDef.MinHeight, sizeDef.MinWidth, sizeDef.MaxHeight, sizeDef.MaxWidth);
}
else
{
// we choose randomly from the list of available sizes
List availableSizes = SizeDefinitions.GetListOfAvailableSizes();
Random rnd = new Random();
string chosenSizeName = availableSizes[rnd.Next(availableSizes.Count)]; SizeDefinition sizeDef = SizeDefinitions.DefinitionsDictionary[requestedSize]; this.Size = new InstanceSize(sizeDef.MinHeight, sizeDef.MinWidth, sizeDef.MaxHeight, sizeDef.MaxWidth);
}
}
}
With the instance size chosen, we need to choose the actual size. While we set limits for each of the sizes, we need to randomize the sizes, up to these max limits. Those new sizes are the sizes we use to set the class members.
Special note
When I first introduced the algorithm for procedurally generated levels, the first 2 stages I wrote were:
ChooseSize(); GenerateDimensions();
Turns out, we can do both steps as 1 step - GenerateSize() ! Hooray! But that's the nature of the work. As we progress with our algorithm, we'll notice changes in the way we want to code it. The algorithm will develop and mature, as we write it.
Conclusion
With that, we've taken our first step toward procedurally generated 2D levels: choosing a size. Of course, we need to properly test the code, and see it run (which I did!), but we're now good to continue to the next stage: choosing a theme!
Cya next time!
Comments