Project Description

Slime – A Dungeon Escape

TEAM
Deliverone FutureGames Game Project 1
Category

Team Projects

Year

2021

Project Duration

4 weeks

Context

FutureGames Game Project 4

Tools

Unreal Engine, C++

My Responsibilities

Procedural Generation System, Level Module Editor, AI Enemies

Team Members

Programmers
Oliver Lebert
Esteban Morales

Designers
Fabian Adlertz Bertzen
Joseph Dominguez-Marreo
Damian Becedas
Eddie Herrera

Music
Eddie Herrera
Jorge Morales

DOWNLOAD GAME
BROWSE CODE

TL;DR

Slime – A Dungeon Escape was a real deep dive into Unreal Engines source code from my part. Most notably I made a level module editor tool which converts textures into blueprint actors that are saved into the content browser. Those are then used by the procedural generator to randomly generate a level.

Project Breakdown

Introduction

Slime – A Dungeon Escape is a roguelike 2D-platformer inspired by games like Spelunky & Kirby. You play as Slimy, a slime that works in a dungeon for an evil empire but after being splashed by a freewill potion, wants to escape. You must find the exit of procedurally generated levels while picking up power-ups & combining them to aid in combat & platforming.

We really liked the ideas of slimes and wanted to make a deconstruction of the fantasy genre. It was quickly that we wanted a roguelike platformer with much replay value, so procedural generation was an obvious choice, so we thought of Spelunky but in reverse. The feeling of experimentation was also really important as well as progression so we made the slime have different power-ups that could be combined Inspired by Kirby 64 in particular. The game is intended for all ages but is challenging for all as well.

This was my second big project in unreal and the team worked super well together. We seemed to be totally synced in the idea and didn’t encounter any big conflicts. This was also my first FutureGames game project without artists which was limiting in some ways, but with a smaller team I felt that it lead to a higher chance of people agreeing.

Procedural Generation System

One of the more notable things I made during this project was the procedural generation system. For the project we had to pick one programming challenge and one design challenge. Since I’ve been wanting to implement a procedural generation system for a while I pushed for that programming challenge which the team also thought was cool (the design challenge we picked was no HUD). I spent the first two weeks on the procedural generation system and the level module editor (see below). Since we had chosen to do a 2.5d side scroller I decided together with the designers that it would be easiest to create level modules (that are manually crafted) which are parts of the bigger level.

The level modules are all blueprint actor classes deriving from a main LevelModule cpp class, you can then add ModuleWall’s which is a subclass of UStaticMeshComponent. And you also add ModuleDoor’s where you want the player/exit door to spawn, ModuleEnemy’s where you want enemies to spawn, ModuleItem’s where you want items, coins or lives to spawn. All of these are billboard components that the procedural generator will use to randomly choose what will spawn at that location based on a list of weights (or pick a random billboard component in the case of the ModuleDoor after picking which LevelModule to spawn the player/door in). See below for the templated method I used for determining which class to use based on the weights.

template <class T>
UClass* AFGProceduralGenerator::GetRandomClassBasedOnWeights(UObject* WorldContextObject, TArray<T>& WeightStruct)
{
	TArray<T> Weights = WeightStruct;

	float TotalWeight = 0.f; // Divide weights by total amount of all weights to get percentage
	for(T Weight : Weights)
	{
		TotalWeight += Weight.Weight;
	}

	const float Random = FMath::FRand();
	float PreviousPercentage = 0.f;

	for(T Weight : Weights)
	{
		PreviousPercentage += Weight.Weight / TotalWeight;

		if(Random < PreviousPercentage)
		{
			if(Weight.Class == nullptr)
			{
				UE_LOG(LogProceduralGeneration, Warning, TEXT("This should never happen, yet it did"));
				return nullptr;
			}
			return Weight.Class;
		}
	}
	return nullptr;
}

When the level modules are done, they can be assigned in a list in the procedural generator together with a DataAsset which specify the size of the level (in level modules), size of the level module (in tiles), size of each tile in the level module (in units) as well as all the weights for items, enemies and obstacles (we didn’t have random obstacles). This is what the data asset interface looked like:

The level modules will be picked at random as long as the corridors between the level modules fit together. For each level module that is not the first one I am creating a list of level modules that are valid and picking randomly from that list. To determine which level module is valid I have an array of int’s in each level module (this either has to be manually specified or if using the level module editor it will be automatically filled in). The int’s in that array are defined as which index on each side the corridor has, index 0 is always at the left/top depending on which side we’re checking. If the corridor is multiple tiles wide, the lowest tile index is used (note that the entire level has to have the same corridor width since it’s defined in the data asset.

Since there is a chance that there might be no valid level modules to pick from if the corridors don’t line up, I have decided to handle that by not spawning anything at that location. But this will mean that the level might look super weird if the designers haven’t made level modules that will cover all of those edge cases. So therefore I am starting the level generation from the center and building out in all directions sequentially. So if there are level modules that can’t generate, the holes will only be in the corners which will make the level look quite good regardless if there are holes. This is how the generation looks:

When the whole level has been generated I patch up any corridors that lead nowhere using the same level module walls. Then I determine all the items, enemies and where the player/exit should spawn. The player spawn will preferably be in either the lower left or lower right corner module while the exit door will be in either the top left or top right corner module. If these two preferred modules doesn’t exist, a random level module will be picked instead.

The Level Module Editor

When the procedural generator was working I realized that it was quite annoying still to make the level modules manually. The designers had to create walls, copy, move, snap to grid etc. in the blueprint editor. They also had to manually specify the index of the corridor which took a while to explain how it worked and lead to a few errors when they didn’t specify it correctly. This lead me to make a tool to smoothen this whole process. While planning how it would work I initially wanted a separate editor window where they could “draw” how the level module would look. While I knew how to do this in Unity, I had no idea how I would do it in Unreal, my guess was slate which I didn’t had time to learn during this project.

Then I realized that I could just pass in pixelart that would be drawn in an external pixel art editor and then somehow convert that to a blueprint asset. Since the source code is open source for unreal I thought it shouldn’t be too hard to figure it out. I spent about a day just reading the source code to find the correct method to do what I wanted. I would load the texture, create an actor, and if the pixel color matched a pixel defined in the color mappings it would spawn an actor component or child actor component. Below is the interface for the color mappings in the data asset.

After a while I found the correct method to use in UKismetEditorUtilities which was called CreateBlueprintFromActor. It takes an actor pointer and a path, which saves an actor blueprint to the path specified matching the signature of the input actor pointer. That sounded pretty straight forward, so I wrote the rest of the code where it reads from the texture, loops over each pixel, adds components etc. This would be the input/output

Input Texture

Ouput Blueprint Actor Class

But then when I tested it out, it didn’t work. It saved a blueprint in the content browser, but no components were added to it even though I called the add component method on the actor I had just spawned in the currently active level (in editor). First I thought that you couldn’t add components to actors at all with code, but that wasn’t true. I spent several days trying to debug it and trying to figure out why it didn’t work. I started to give up hope and sadly told my designers that I had no idea what the problem is or even how to begin to solve it so I probably shouldn’t spend valuable project time to keep working on it.

Then, a day or so after I had abandoned my super flashy level editor I had moved on to other stuff instead. But while waiting for Esteban to be done with a class so I could add some stuff to it, I decided to go back to the level editor since it had now been a few days, maybe I could solve it with fresh eyes. I searched for usages of AddComponentsToBlueprint (because I thought that this method might work but I didn’t know how to supply all the parameters). And then I found it, they added components using AddInstanceComponent instead of the normal method used during runtime. I tried changing the method I called when adding components to the actor in the scene to that and boom it worked. I was so happy I figured it out, and could gladly tell the designers that they could now use the tool anyway because I figured it out. Below you can see how you use the tool (the code itself was in a BlueprintFunctionLibrary which is called through an asset action utility, you can select multiple textures at once to do them all in a batch).

void UFGLevelEditorUtility::CreateLevelModules(TArray<UTexture2D*> LevelSchematics, UFGLevelDataSpecs* DataSpecs)
{
#if WITH_EDITOR
	UE_LOG(LogLevelEditorUtility, Log, TEXT("Tried to create level modules"));

	UWorld* World = GEditor->GetEditorWorldContext().World();
	
	int ModuleSize = DataSpecs->ModuleSize;
	for (int i = 0; i < LevelSchematics.Num(); i++)
	{
		UTexture2D* Texture = LevelSchematics[i];
		AFGLevelModule* LevelModuleInstance = World->SpawnActor<AFGLevelModule>(AFGLevelModule::StaticClass());
		
		FString Name = FString(TEXT("BP_") + Texture->GetName());
		FString PackageName = FString(TEXT("/Game/_Game/Blueprints/LevelModules/")) + Name;
		if (UEditorAssetLibrary::DoesAssetExist(PackageName))
		{
			UE_LOG(LogLevelEditorUtility, Error, TEXT("Couldn't create a blueprint with name %s since it already exists, please delete it first"), *Name);
			continue;
		}
		if(ModuleSize != Texture->PlatformData->Mips[0].SizeX || ModuleSize != Texture->PlatformData->Mips[0].SizeY)
		{
			UE_LOG(LogLevelEditorUtility, Error, TEXT("Couldn't create a blueprint with name %s since the resolution of the texture doesn't match the module size defined in LevelDataSpecs"), *Name)
			continue;
		}
		
		ParseTexture(LevelModuleInstance, Texture, DataSpecs);

		FKismetEditorUtilities::FCreateBlueprintFromActorParams Params;
		Params.bOpenBlueprint = false;
		UBlueprint* Blueprint = FKismetEditorUtilities::CreateBlueprintFromActor(PackageName, LevelModuleInstance, Params);
	}
#endif
}
// Called in ParseTexture for each pixel (the relative position this component should have is also calculated there)
void UFGLevelEditorUtility::ParsePixelColor(AFGLevelModule* LevelModule, FColor PixelColor, FVector RelativePosition, UFGLevelDataSpecs* DataSpecs, int xCoord, int yCoord)
{
	const int ModuleSize = DataSpecs->ModuleSize;

	if(FMath::Abs(PixelColor.A) <= ColorTolerance)
	{
		if(LevelModule->DoorLocations[Up] == -1 && yCoord == 0)
			LevelModule->DoorLocations[Up] = xCoord;
		
		if(LevelModule->DoorLocations[Left] == -1 && xCoord == 0)
			LevelModule->DoorLocations[Left] = yCoord;
		
		if(LevelModule->DoorLocations[Down] == -1 && yCoord == ModuleSize - 1)
			LevelModule->DoorLocations[Down] = xCoord;
		
		if(LevelModule->DoorLocations[Right] == -1 && xCoord == ModuleSize - 1)
			LevelModule->DoorLocations[Right] = yCoord;

		return;
	}
	
	for(FColorMappingData Set : DataSpecs->ColorMappings)
	{
		if(!Set.Class && !Set.ActorClass)
			continue;
		
		if(!ColorsAreAlmostEqual(Set.Color, PixelColor))
			continue;

		USceneComponent* Component;
		if(Set.ClassTypeToUse.GetValue() == ActorComponent)
		{
			Component = NewObject<USceneComponent>(LevelModule, Set.Class, FName(TEXT("Feature_") + FString::FromInt(xCoord) + TEXT(",") + FString::FromInt(yCoord)), RF_Transactional);
		}
		else
		{
			Component = NewObject<UChildActorComponent>(LevelModule, UChildActorComponent::StaticClass(), FName(TEXT("Feature_") + FString::FromInt(xCoord) + TEXT(",") + FString::FromInt(yCoord)), RF_Transactional);
			Cast<UChildActorComponent>(Component)->SetChildActorClass(Set.ActorClass);
		}

		LevelModule->AddInstanceComponent(Component);
		Component->AttachToComponent(LevelModule->GetRootComponent(), FAttachmentTransformRules(EAttachmentRule::KeepWorld, false));
		Component->SetRelativeLocation(RelativePosition);
	}
}

bool UFGLevelEditorUtility::ColorsAreAlmostEqual(const FColor A, const FColor B, const int Tolerance)
{
	return FMath::Abs(A.R - B.R) <= Tolerance && FMath::Abs(A.G - B.G) <= Tolerance && FMath::Abs(A.B - B.B) <= Tolerance && FMath::Abs(A.A - B.A) <= Tolerance;
}