My Shooter Mega-Mega Challenge Progress

As a follow up to my first mega shooter showcase: Added "Enemies Left" to the HUD I’ve still been plugging away adding features trying to work at least 1 hour a day on the game.

As a companion to the Unreal C++ course, I’ve found Stephen Ulibarri’s course https://www.udemy.com/course/unreal-engine-the-ultimate-game-developer-course very helpful in providing me ideas for my game. I see now he is part of the GameDev.Tv team :slight_smile:

I also found using Trello and a Kanban template along with the Chrome/Brave extension Trello Plus insanely helpful in keeping my project organized and tracking my time so I get better at estimation which will be important for my future projects.

Once I get through my MVP features want to share a build.

Features Implemented

  • AI behavior improvements: Patrolling, react to fire, searching, taunts you after killing you
  • Camera shake when hit and slight when shooting (inspired by toon tanks)
  • Silly homemade sounds for hit and death of enemies
  • Adding crouch and animation - AI can crouch as well
  • Gun Recoil - adds random offsets to aim after shooting. Tweakable so guns with bigger kick can have bigger recoil
  • Main menu with New Game, Continue, Exit
  • Controller support in menus: HOW to CREATE a GAMEPAD Menu Unreal Engine 4 - YouTube
  • Location specific damage (i.e. headshots) - [Tutorial] Implement locational damage (headshots) - #2 by tryfinsigabrt
  • Pickups for health and ammo (tied to gun type) - originally inspired by Stephen’s course
  • Enemy and item spawn system (Inspired by Stephen’s SpawnVolume class) - Spawn N items of a type in M different possible locations
  • Ammo for gun and reload mechanics (sounds, animations) - Penalize player for early reloads by discarding the remaining ammo in magazine when swapping in a new one. Used Mixamo for the reload animation and had to hack it with animation tracks as the “weapon bone” was translated and rotated weird after import: Editing Animation Layers | Unreal Engine Documentation Used an animation montage described in Stephen’s course and then this insanely helpful tutorial to blend just the upper body reload animation with existing locomotion: How To Blend Animations In Unreal Engine 4 - YouTube
  • Save games with auto save. Inspired by Stephen’s Save Game section. I created a SaveGame abstraction triggered from game mode and then have an AutoSave actor I can drop into world that saves during non-combat sections in game after a cooldown period. This then integrates with the pause menu via (reload checkpoint) or from “Continue” in main menu

In Progress:

  • Adding pistol and assault rifle and weapon switching mechanic

Todo:

  • Scoring system and high scores and ability to post to a central server (Plan to stand up a NodeJS lambda, Dynamo DB, and then have an API Gateway endpoint to post. Use a checksum on saved games as well as encrypting the payload at rest to discourage cheating - not perfect but at least defeats proxy and relay attacks)
  • Announcer sound effects - Use Paragon announcer assets
  • Multiple levels (5) with 4 waves of increasing difficulty in each for a total of 20 total zones
  • Polishing and other bug fixes
2 Likes

Very in-depth. I love how you labeled all the features you added. This is actually giving me ideas on how to organize how I complete tasks for my project. Also I never thought about using trello to store my tasks. Thank you for this post.

1 Like

It’s been a while again, and I’ve continued to go through my task list in Trello. I’ve also been working through the blueprints course which has really made me come around to the visual coding concept and see more possibilities for my games, particularly bringing order to what I saw as chaos in visual scripting by organizing things into function or macro libraries.

Wanted to take this time to share a couple of things I learned that I found interesting in Unreal. The first is with my work on the scoring system in the game. When I started this task I knew I wanted to utilize an “observer pattern”. The reason being I didn’t want to spew the scoring computations all over the code base. I wanted all the details of the scoring system that I’d likely keep tuning to be all in one place. On games I’ve worked on before I’ve typically rolled my own system creating a generic “ListenerManager” or such where I could keep the common details of registering observers and then invoking a function to notify them. Turns out Unreal does this borrowing a concept from C# called “Delegates”. In particular I wanted to be able to have the flexibility of broadcasting or registering events from both C++ and blueprints. I did a bit of googling and came across “Multi-cast delegates”. “Multi-cast” means the events are not 1:1 but 1:many. We can register multiple listeners for a given event and they are broadcast to all of them. In my case I really only needed a single observer but liked the flexibility.

In my research these 3 links were the most helpful:

  1. Basic concepts in the docs: Delegates | Unreal Engine Documentation
  2. This thread from the unreal forums: C++ Event dispatchers: How do we implement them? - C++ Programming - Unreal Engine Forums
  3. This youtube video explaining how to do this in blueprints: The Observer Pattern in Unreal Engine 4 using Event Dispatchers - YouTube

The steps are basically

  1. Declare your delegate with a macro at top of header file containing class where you want to broadcast the event from

  2. Define an instance of it in the class and annotate it with appropriate UPROPERTY macro parameters

  3. (Optional) Define an accessor function in your C++ class for registering a listener from C++

  4. Broadcast the event from your class either in blueprints or C++

  5. Register listeners from either C++ or blueprints

  6. In C++ it boiled down to the following taking a sample from my code.

DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FPlayerGunOnPickup, const AGun*, PickedUpGun);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FPlayerAmmoOnPickup, const FGunAmmo&, AmmoAdded);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FPlayerHealthOnPickup, float, HealthAdded);

The first argument is always the name of the delegate and then optionally the additional arguments are passed in form type and then name. One interesting thing I found was that the delegate parameters either needed to be primitive types like float or classes/structs defined from UObject or UStruct. If the class was a UObject like the AGun actor here it MUST be declared as a pointer argument. The const here is optional but I wanted to enforce that we cannot modify the object in the receiver. If the class was a UStruct like FGunAmmo here it MUST be passed by value or by const reference. It cannot be passed by pointer. This is because UObjects are garbage collected and so the generated class from the macro can store a UPROPERTY to it, which reference counts it and prevents it from being deleted from underneath the listener (or possibly a weak reference and ensures it is only called on a valid or live object). Similarly, UStructs DO NOT participate in garbage collection and so we cannot pass a pointer and guarantee that the object will still be valid when the listener is invoked - so the delegate abstraction must store a copy to it.

2 and 3 ) Define an instance of it and expose an accessor for C++ to bind/register events

Here I like to make the instance private (some unreal code and blogs have this public but I see this as a state variable and prefer to keep it private when accessed from C++). However, from blueprints I do not mind exposing it via the “meta” tag as I can control read only vs writable via other UPROPERTY variables. From C++ I expose a public accessor to invoke, which unfortunately does not let me limit broadcast vs bind via a const qualifier.

// ...
public:
  FOnGunShotMissed& GetOnGunShotMissed();

// ...
private:
	UPROPERTY(Category = "Notification", BlueprintAssignable, meta = (AllowPrivateAccess = "true"))
	FOnGunShotMissed OnGunShotMissed;

The important qualifiers are confusingly BlueprintAssignable and what I do not have listed here BlueprintCallable which have specific semantics on delegates.

a) BlueprintAssignable : Allows you to register or bind a listener from blueprints. This is the receiver callback when the event fires
b) BlueprintCallable: Allows you to broadcast or fire the event from blueprints. I do not add that here since I am actually broadcasting from C++ and do not want an outside blueprint class to be able to fire events erroneously.

  1. Broadcasting the event

a) From C++:

OnGunShotMissed.Broadcast(this);

We invoke the Broadcast member function and pass any arguments directly. Here we pass this because the first parameter is the AGun actor pointer and the event is broadcast from AGun itself

b) From blueprints

This came up in an “On Enemy Alerted” event I wanted to fire from my behavior tree to add a stealth bonus on stealth. I decided to implement this in a blueprint task as the functionality was simple. I declared the event in my shooter player controller. Here I use BlueprintAssignable only as I want to be able to broadcast from blueprints:

	UPROPERTY(Category = "Notification", BlueprintAssignable, BlueprintCallable, meta = (AllowPrivateAccess = "true"))
	FOnEnemyAlerted OnEnemyAlerted;

Then from blueprints we broadcast via the “Call” node on the delegate:

  1. Registering listener

a) From C++

We use the AddDynamic function on the delegate and pass a member function pointer to our function we want to notify on our class. This function can be private, which is nice for encapsulation. The function MUST be declared as a UFUNCTION - something I often forget and then get a nasty runtime exception in Unreal :slight_smile:

Here I forward declare the AGun class inline with the parameter declaration, which is something I didn’t even know you could do in C++ before the game dev courses (I thought you could only forward declare at the top of the file). Forward declaring reduces build time coupling as we do not need to include the header which has a cascading effect when you change something and everything needs to rebuild.

UFUNCTION()
void OnPlayerGunShotMissed(const class AGun* Gun);

Then in a BeginPlay function for actors or some kind of Init function you declare on a plain UObject you can register your listener:

PlayerCurrentGun->GetOnGunShotMissed().AddDynamic(this, &UScorer::OnPlayerGunShotMissed);

Visual Studio really does not like AddDynamic so bear with the bad autocompletion or squiggles until you do a build - they will go away.

b) From blueprints

We call “Bind Event” on the BlueprintAssignable delegate field and then can “Add a custom Event” in our main blueprint graph (and name it anything but good to name it the same as the event itself). And then we can drag off additional nodes from there. I found out the hard way that you cannot bind an event from a blueprint function, it seems to have to be from the main event graph. To encapsulate things you can just dispatch directly to another function/macro like I did here with the “Add Messages” macro.

Here I am adding toast messages in the top right corner of the screen when the player picks up an item.

A second thing I recently implemented that I found interesting was adding multiple levels. Going through the blueprints course and “Marble Run” there is a simple way to do this if you do not need to save progress - just create an array of all your level names and then find which one you are at by looking for the index of that name in the list. Then it is easy to “+1” it and move to next or know you completed the game. One important thing to note is that EVERY actor in your game, including the game mode and game state are completely destroyed and recreated when a new level is loaded. That means you cannot track the current overall “game state” in the game state itself. The only object that is a true singleton and is only created once per game is the “GameInstance”. So you are left with two ways to store state between level loads

  1. Create a saved game object and save and then reload the file between level loads
  2. Store this state on the GameInstance itself temporarily to shuffle it between levels

Since I was already saving the game after completing each level, I chose the first option. Note though you have to gain a deep understanding of the start sequence of a level through to begin play to get the loading right so it is a bit more challenging than option 2.

For each level I have a spawning system I learned from Stephen’s game dev course to spawn enemies and items in a random set of locations to make the game a bit more varied and procedural. I have a set of parameters I want to tweak between levels. I divide each unique map into a “level” and some of the levels have multiple “waves” I call “zones”. To coordinate transitioning the state, I have a “manager” class callled “ULevelLauncher”. This handles actually loading the level with the appropriate zone data, including the map name. So I have

ULevelLauncher -> FLevelConfig[] -> FLevelZoneConfig[]

The current level data is then in a FLevelState struct to keep track of where you are, which is what gets persisted in the saved game.

I keep a TArray of the LevelZoneConfig on the game mode itself and then expose it to blueprints so that I can easily tweak it when I put my level designer hat on:

Here is a snippet of that blueprint variable:

image

“Player Start Tag” allows me to select from a number of spawn locations as in some of the larger maps I start the player off in a different location. Figuring out how to do that was a whole other thing :slight_smile: I found the “Shooter Game” full game example from the marketplace helpful in getting me there. One way to do that is to override the ChoosePlayerStart_Implementation function in the AGameModeBase (which is interestingly not even declared in the base class but you can override it like it is - something I don’t yet fully understand). The source code of the “Shooter Game” shows an implementation of this and I took that plus the source code of the ChoosePlayerStart and then had to figure out some start order issues - again looking at the unreal source code to understand it better to be sure the level state was initialized by the time this function was called as the sequencing was not well documented.

Anyway, back to the level loader code. I’ve included my full source code for it and the unit tests I used to test some of it below in case it helps anyone else:

LevelLauncher.h

// Fill out your copyright notice in the Description page of Project Settings.

#pragma once

#include "CoreMinimal.h"
#include "UObject/NoExportTypes.h"

#include "AIDifficulty.h"

#include "LevelLauncher.generated.h"



USTRUCT(BlueprintType)
struct FLevelZoneConfig
{
	GENERATED_BODY()

	UPROPERTY(Category = "Level | Enemies", EditDefaultsOnly, BlueprintReadOnly)
	EAIDifficulty Difficulty {};

	UPROPERTY(Category = "Level | Enemies", EditDefaultsOnly, BlueprintReadOnly)
	int32 NumEnemies {};

	UPROPERTY(Category = "Level | Items", EditDefaultsOnly, BlueprintReadOnly)
	int32 NumHealthItems {};

	UPROPERTY(Category = "Level | Items", EditDefaultsOnly, BlueprintReadOnly)
	int32 NumAmmoItems {};

	UPROPERTY(Category = "Level | Time", EditDefaultsOnly, BlueprintReadOnly)
	int32 TimeTargetSeconds {};

	UPROPERTY(Category = "Level | Player", EditDefaultsOnly, BlueprintReadOnly)
	FName PlayerStartTag {};

	UPROPERTY(Category = "Level | Enemies", EditDefaultsOnly, BlueprintReadOnly)
	float MaxLineOfSightDetectionDistance {};

	UPROPERTY(Category = "Level | Sound", EditDefaultsOnly, BlueprintReadOnly)
	USoundBase* IntroMusic {};
};

USTRUCT(BlueprintType)
struct FLevelConfig
{
	GENERATED_BODY()

	UPROPERTY(Category = "Level", EditDefaultsOnly, BlueprintReadOnly)
	FName LevelName {};

	UPROPERTY(Category = "Level | Zones", EditDefaultsOnly, BlueprintReadOnly)
	TArray<FLevelZoneConfig> Zones;
};

USTRUCT(BlueprintType)
struct FLevelState
{
	GENERATED_BODY()

	UPROPERTY(VisibleAnywhere, Category = Data, SaveGame)
	FName LevelName {};

	UPROPERTY(VisibleAnywhere, Category = Data, SaveGame)
	int32 LevelIndex {};

	UPROPERTY(VisibleAnywhere, Category = Data, SaveGame)
	int32 ZoneIndex {};

	UPROPERTY(VisibleAnywhere, Category = Data, SaveGame)
	bool bMultipleZones {};

	FString ToString() const;

	FLevelState() = default;
	explicit FLevelState(const FLevelConfig& LevelConfig, int32 LevelIndex = 0, int32 ZoneIndex = 0);

	void SetLevelConfig(const FLevelConfig& LevelConfig);
};

/**
 * 
 */
UCLASS(BlueprintType)
class SIMPLESHOOTER_API ULevelLauncher : public UObject
{
	GENERATED_BODY()

public:
	void Init(UWorld* World, const TArray<FLevelConfig>& Levels, int32 StartLevelIndex = 0, int32 StartZoneIndex = 0);


	bool HasNextZone(const FLevelState& CurrentLevelState) const;
	bool HasNextLevel(const FLevelState& CurrentLevelState) const;

	bool NextZone(const FLevelState& CurrentLevelState, FLevelState* OutNextZoneState=nullptr, FLevelZoneConfig* OutZoneConfig=nullptr) const;
	bool LoadZone(const FLevelState& CurrentLevelState) const;
	void FirstZone(FLevelState* outFirstZoneState, FLevelZoneConfig* OutZoneConfig = nullptr) const;

	bool GetZoneConfig(const FLevelState& LevelState, FLevelZoneConfig& OutZoneConfig) const;

private:

	void AssertState() const;

	bool GetLevelConfig(const FLevelState& LevelState, const FLevelConfig** OutLevelConfig = nullptr) const;
	bool GetZoneConfig(const FLevelState& LevelState, const FLevelConfig& LevelConfig, const FLevelZoneConfig** OutLevelZoneConfig = nullptr) const;

private:
	UPROPERTY()
	UWorld* World;

	UPROPERTY(Category = "Level", EditDefaultsOnly)
	int32 StartLevelIndex {};

	UPROPERTY(Category = "Level", EditDefaultsOnly)
	int32 StartZoneIndex {};

	UPROPERTY(Category = "Level", EditDefaultsOnly)
	TArray<FLevelConfig> Levels;
};


inline FString FLevelState::ToString() const
{
	return bMultipleZones ? FString::Printf(TEXT("%d-%d"), LevelIndex + 1, ZoneIndex + 1) :
		FString::Printf(TEXT("%d"), LevelIndex + 1);
}

inline bool ULevelLauncher::HasNextZone(const FLevelState& CurrentLevelState) const
{
	AssertState();
	return NextZone(CurrentLevelState);
}

LevelLauncher.cpp

// Fill out your copyright notice in the Description page of Project Settings.


#include "LevelLauncher.h"

#include "Kismet/GameplayStatics.h" 

FLevelState::FLevelState(const FLevelConfig& LevelConfig, int32 LevelIndex, int32 ZoneIndex)
	: LevelName(LevelConfig.LevelName), LevelIndex(LevelIndex), ZoneIndex(ZoneIndex), bMultipleZones(LevelConfig.Zones.Num() > 1)
{

}

void ULevelLauncher::Init(UWorld* TheWorld, const TArray<FLevelConfig>& TheLevels, int32 TheStartLevelIndex, int32 TheStartZoneIndex)
{
	UE_LOG(LogTemp, Display, TEXT("%s: initialized with %d level(%s); StartLevelIndex=%d; StartZoneIndex=%d"),
		*GetName(), TheLevels.Num(), (TheLevels.Num() > 1 ? TEXT("s") : TEXT("")),
		StartLevelIndex, StartZoneIndex);

	this->World = TheWorld;
	this->Levels = TheLevels;
	this->StartLevelIndex = TheStartLevelIndex;
	this->StartZoneIndex = TheStartZoneIndex;

	AssertState();

	for (const auto& level : Levels)
	{
		if (level.LevelName.IsNone() || !level.Zones.Num())
		{
			UE_LOG(LogTemp, Fatal, TEXT("%s: Init - Invalid level set with name %s and zone count %d"), *GetName(), *level.LevelName.ToString(), level.Zones.Num());
			return;
		}
	}
}

void ULevelLauncher::AssertState() const
{
	if (!World)
	{
		UE_LOG(LogTemp, Fatal, TEXT("%s: AssertState - World is NULL!"), *GetName());
		return;
	}

	if (!Levels.Num())
	{
		UE_LOG(LogTemp, Fatal, TEXT("%s: AssertState - no levels configured!"), *GetName());
		return;
	}
}

bool ULevelLauncher::NextZone(const FLevelState& CurrentLevelState, FLevelState* OutNextZoneState, FLevelZoneConfig* OutZoneConfig) const
{
	AssertState();

	const FLevelConfig* LevelConfig;
	if (!GetLevelConfig(CurrentLevelState, &LevelConfig))
	{
		return false;
	}

	// check if there is a next zone in current level
	const FLevelZoneConfig* ZoneConfig;
	FLevelState NextZoneLevelState(CurrentLevelState);
	++NextZoneLevelState.ZoneIndex;

	if (GetZoneConfig(NextZoneLevelState, *LevelConfig, &ZoneConfig))
	{
		if (OutNextZoneState)
		{
			*OutNextZoneState = NextZoneLevelState;
		}
		if (OutZoneConfig)
		{
			*OutZoneConfig = *ZoneConfig;
		}

		return true;
	}

	// First zone in next level is the "NextZone"
	++NextZoneLevelState.LevelIndex;
	NextZoneLevelState.ZoneIndex = 0;

	if (!GetLevelConfig(NextZoneLevelState, &LevelConfig))
	{
		return false;
	}

	NextZoneLevelState.SetLevelConfig(*LevelConfig);

	if (!GetZoneConfig(NextZoneLevelState, *LevelConfig, &ZoneConfig))
	{
		return false;
	}

	if (OutNextZoneState)
	{
		*OutNextZoneState = NextZoneLevelState;
	}
	if (OutZoneConfig)
	{
		*OutZoneConfig = *ZoneConfig;
	}
	

	return true;
}

void FLevelState::SetLevelConfig(const FLevelConfig& LevelConfig)
{
	bMultipleZones = LevelConfig.Zones.Num() > 1;
}

bool ULevelLauncher::LoadZone(const FLevelState& CurrentLevelState) const
{
	AssertState();

	const FLevelConfig* LevelConfig;
	if (!GetLevelConfig(CurrentLevelState, &LevelConfig))
	{
		return false;
	}

	UE_LOG(LogTemp, Display, TEXT("Loading LevelName: %s"), *LevelConfig->LevelName.ToString());

	UGameplayStatics::OpenLevel(World, LevelConfig->LevelName);

	return true;
}

void ULevelLauncher::FirstZone(FLevelState* outNextZoneState, FLevelZoneConfig* OutZoneConfig) const
{
	AssertState();

	// Try and honor any starting level and zone overrides
	const FLevelConfig* LevelConfig(&Levels[0]);
	int32 FirstLevelIndex{ StartLevelIndex };
	int32 FirstZoneIndex{ StartZoneIndex };

	FLevelState FirstZoneState(*LevelConfig, FirstLevelIndex, FirstZoneIndex);
	FLevelZoneConfig FirstZoneConfig;

	if (!GetLevelConfig(FirstZoneState, &LevelConfig))
	{
		// invalid level config - reset to first level and zone
		FirstZoneConfig = Levels[0].Zones[0];
		FirstLevelIndex = FirstZoneIndex = 0;
	}
	else if (!GetZoneConfig(FirstZoneState, FirstZoneConfig))
	{
		// invalid zone config - keep level but reset zone to index 0 for that level
		FirstZoneConfig = LevelConfig->Zones[0];
		FirstZoneIndex = 0;
	}

	// ensure all state synced properly - i.e. level config
	FirstZoneState = FLevelState(*LevelConfig, FirstLevelIndex, FirstZoneIndex);

	if (outNextZoneState)
	{
		*outNextZoneState = FirstZoneState;
	}
	if (OutZoneConfig)
	{
		*OutZoneConfig = FirstZoneConfig;
	}
}

bool ULevelLauncher::HasNextLevel(const FLevelState& CurrentLevelState) const
{
	AssertState();

	const FLevelConfig* LevelConfig;
	if (!GetLevelConfig(CurrentLevelState, &LevelConfig))
	{
		return false;
	}

	// set to last zone of current valid level
	FLevelState LastZoneStateCurrentLevel(CurrentLevelState);
	LastZoneStateCurrentLevel.ZoneIndex = LevelConfig->Zones.Num() - 1;

	return NextZone(LastZoneStateCurrentLevel);
}

bool ULevelLauncher::GetZoneConfig(const FLevelState& LevelState, FLevelZoneConfig& OutZoneConfig) const
{
	const FLevelConfig* LevelConfig;
	if (!GetLevelConfig(LevelState, &LevelConfig))
	{
		return false;
	}

	const FLevelZoneConfig* ZoneConfig;
	if (!GetZoneConfig(LevelState, *LevelConfig, &ZoneConfig))
	{
		return false;
	}

	OutZoneConfig = *ZoneConfig;

	return true;
}

bool ULevelLauncher::GetLevelConfig(const FLevelState& LevelState, const FLevelConfig** OutLevelConfig) const
{
	if (LevelState.LevelIndex < 0 || LevelState.LevelIndex >= Levels.Num())
	{
		return false;
	}

	if (OutLevelConfig)
	{
		*OutLevelConfig = &Levels[LevelState.LevelIndex];
	}

	return true;
}

bool ULevelLauncher::GetZoneConfig(const FLevelState& LevelState, const FLevelConfig& LevelConfig, const FLevelZoneConfig** OutLevelZoneConfig) const
{
	AssertState();

	if (LevelState.ZoneIndex < 0 || LevelState.ZoneIndex >= LevelConfig.Zones.Num() || 
		LevelState.LevelIndex < 0 || LevelState.LevelIndex >= Levels.Num())
	{
		return false;
	}

	if (OutLevelZoneConfig)
	{
		*OutLevelZoneConfig = &Levels[LevelState.LevelIndex].Zones[LevelState.ZoneIndex];
	}

	return true;
}

LevelLauncherTest.cpp

#include "CoreTypes.h"
#include "Containers/UnrealString.h"
#include "Misc/AutomationTest.h"
#include "Engine/World.h"

#include "LevelLauncher.h"


#if WITH_DEV_AUTOMATION_TESTS

namespace
{
	ULevelLauncher* NewLauncher();
	TArray<FLevelConfig> CreateLevels();

	const TArray<FLevelConfig> Levels = CreateLevels();
}

BEGIN_DEFINE_SPEC(LevelLauncherTest, "LevelLauncher", EAutomationTestFlags::ApplicationContextMask | EAutomationTestFlags::ProductFilter)
END_DEFINE_SPEC(LevelLauncherTest)

void LevelLauncherTest::Define() {
	Describe("FLevelState::ToString", [this]() {
		It("Should Return 1-1 for first level and zone", [this]() {
			FLevelState LevelState(Levels[0]);

			TestEqual(TEXT("Level 1-1"), *LevelState.ToString(), TEXT("1-1"));
		});
		It("Should Return 1-2 for first level and second zone", [this]() {
			FLevelState LevelState(Levels[0], 0, 1);

			TestEqual(TEXT("Level 1-2"), *LevelState.ToString(), TEXT("1-2"));
		});
		It("Should Return 2-1 for second level and first zone", [this]() {
			FLevelState LevelState(Levels[0], 1, 0);

			TestEqual(TEXT("Level 2-1"), *LevelState.ToString(), TEXT("2-1"));
		});
		It("Should Return only level name when there is only one zone in the config", [this]() {
			const FLevelConfig LevelConfig
			{
				FName("Level"),
				{
					FLevelZoneConfig
					{
						EAIDifficulty::Hard,
						10,
						1,
						1,
						50
					}
				}
			};
			FLevelState LevelState(LevelConfig, 1, 0);

			TestEqual(TEXT("Level with only one zone"), *LevelState.ToString(), TEXT("2"));
		});
	});
	Describe("HasNextZone", [this]() {
		It("Should Return True on first zone", [this]() {
			auto LevelLauncher = NewLauncher();

			FLevelState LevelState(Levels[0]);
			TestTrue(TEXT("HasNextZone returns true when there is a next zone on same level"), LevelLauncher->HasNextZone(LevelState));
		});
		It("Should Return True on second zone", [this]() {
			auto LevelLauncher = NewLauncher();

			FLevelState LevelState(Levels[0], 0, 1);
			TestTrue(TEXT("HasNextZone returns true when there is a next zone on same level"), LevelLauncher->HasNextZone(LevelState));
		});
		It("Should Return True on last zone of level when next level exists", [this]() {
			auto LevelLauncher = NewLauncher();

			FLevelState LevelState(Levels[0], 0, 2);
			TestTrue(TEXT("HasNextZone returns true when there is a next zone on next level"), LevelLauncher->HasNextZone(LevelState));
		});
		It("Should Return False on last zone of level when next level does not exist", [this]() {
			auto LevelLauncher = NewLauncher();

			FLevelState LevelState(Levels[1], 1, 2);
			TestFalse(TEXT("HasNextZone returns false when there is not a next zone on next level"), LevelLauncher->HasNextZone(LevelState));
		});
	});

	Describe("HasNextLevel", [this]() {
		It("Should Return True on first level", [this]() {
			auto LevelLauncher = NewLauncher();

			FLevelState LevelState(Levels[0],0,1);
			TestTrue(TEXT("HasNextLevel returns true when there is a next level"), LevelLauncher->HasNextLevel(LevelState));
		});
		It("Should Return False on last level", [this]() {
			auto LevelLauncher = NewLauncher();

			FLevelState LevelState(Levels[1], 1, 0);
			TestFalse(TEXT("HasNextLevel returns false when there is no next level"), LevelLauncher->HasNextLevel(LevelState));
		});
	});

	Describe("NextZone", [this]() {
		It("Should Return True on first zone", [this]() {
			auto LevelLauncher = NewLauncher();

			FLevelState LevelState(Levels[0]);

			FLevelState NextLevelStateOne, NextLevelStateTwo;
			FLevelZoneConfig NextZoneConfig;

			TestTrue(TEXT("NextZone returns true when there is a next zone on same level"), LevelLauncher->NextZone(LevelState));
			
			TestTrue(TEXT("NextZone returns true when there is a next zone on same level"), LevelLauncher->NextZone(LevelState, &NextLevelStateOne));
			TestEqual(TEXT("NextLevelState populated"), NextLevelStateOne.LevelIndex, 0);
			TestEqual(TEXT("NextLevelState populated"), NextLevelStateOne.ZoneIndex, 1);

			TestTrue(TEXT("NextZone returns true when there is a next zone on same level"), LevelLauncher->NextZone(LevelState, &NextLevelStateTwo, &NextZoneConfig));
			TestEqual(TEXT("NextLevelState populated"), NextLevelStateTwo.LevelIndex, 0);
			TestEqual(TEXT("NextLevelState populated"), NextLevelStateTwo.ZoneIndex, 1);
			TestEqual(TEXT("NextZoneConfig populated"), NextZoneConfig.TimeTargetSeconds, 90);
		});
		It("Should Return True on second zone", [this]() {
			auto LevelLauncher = NewLauncher();

			FLevelState LevelState(Levels[0], 0, 1);

			FLevelState NextLevelStateOne, NextLevelStateTwo;
			FLevelZoneConfig NextZoneConfig;

			TestTrue(TEXT("NextZone returns true when there is a next zone on same level"), LevelLauncher->NextZone(LevelState));

			TestTrue(TEXT("NextZone returns true when there is a next zone on same level"), LevelLauncher->NextZone(LevelState, &NextLevelStateOne));
			TestEqual(TEXT("NextLevelState populated"), NextLevelStateOne.LevelIndex, 0);
			TestEqual(TEXT("NextLevelState populated"), NextLevelStateOne.ZoneIndex, 2);


			TestTrue(TEXT("NextZone returns true when there is a next zone on same level"), LevelLauncher->NextZone(LevelState, &NextLevelStateTwo, &NextZoneConfig));
			TestEqual(TEXT("NextLevelState populated"), NextLevelStateTwo.LevelIndex, 0);
			TestEqual(TEXT("NextLevelState populated"), NextLevelStateTwo.ZoneIndex, 2);
			TestEqual(TEXT("NextZoneConfig populated"), NextZoneConfig.TimeTargetSeconds, 60);
		});
		It("Should Return True on last zone of level when next level exists", [this]() {
			auto LevelLauncher = NewLauncher();

			FLevelState LevelState(Levels[0], 0, 2);

			FLevelState NextLevelStateOne, NextLevelStateTwo;
			FLevelZoneConfig NextZoneConfig;

			TestTrue(TEXT("NextZone returns true when there is a next zone on next level"), LevelLauncher->NextZone(LevelState));

			TestTrue(TEXT("NextZone returns true when there is a next zone on next level"), LevelLauncher->NextZone(LevelState, &NextLevelStateOne));
			TestEqual(TEXT("NextLevelState populated"), NextLevelStateOne.LevelIndex, 1);
			TestEqual(TEXT("NextLevelState populated"), NextLevelStateOne.ZoneIndex, 0);


			TestTrue(TEXT("NextZone returns true when there is a next zone on next level"), LevelLauncher->NextZone(LevelState, &NextLevelStateTwo, &NextZoneConfig));
			TestEqual(TEXT("NextLevelState populated"), NextLevelStateTwo.LevelIndex, 1);
			TestEqual(TEXT("NextLevelState populated"), NextLevelStateTwo.ZoneIndex, 0);
			TestEqual(TEXT("NextZoneConfig populated"), NextZoneConfig.TimeTargetSeconds, 50);
		});
		It("Should Return False on last zone of level when next level does not exist", [this]() {
			auto LevelLauncher = NewLauncher();

			FLevelState LevelState(Levels[1], 1, 2);
			TestFalse(TEXT("NextZone returns false when there is not a next zone on next level"), LevelLauncher->NextZone(LevelState));
		});
	});

	Describe("FirstZone", [this]() {
		It("Should Return first zone of first level", [this]() {
			auto LevelLauncher = NewLauncher();

			FLevelState FirstLevelStateOne, FirstLevelStateTwo;
			FLevelZoneConfig FirstZoneConfig;

			LevelLauncher->FirstZone(&FirstLevelStateOne);

			TestEqual(TEXT("FirstLevelState populated"), FirstLevelStateOne.LevelIndex, 0);
			TestEqual(TEXT("FirstLevelState populated"), FirstLevelStateOne.ZoneIndex, 0);

			LevelLauncher->FirstZone(&FirstLevelStateTwo, &FirstZoneConfig);

			TestEqual(TEXT("FirstLevelState populated"), FirstLevelStateTwo.LevelIndex, 0);
			TestEqual(TEXT("FirstLevelState populated"), FirstLevelStateTwo.ZoneIndex, 0);
			TestEqual(TEXT("FirstZoneConfig populated"), FirstZoneConfig.TimeTargetSeconds, 120);
		});
	});

	Describe("GetZoneConfig", [this]() {
		It("Should Return True on first zone", [this]() {
			auto LevelLauncher = NewLauncher();

			FLevelState LevelState(Levels[0]);
			FLevelZoneConfig ZoneConfig;

			TestTrue(TEXT("GetZoneConfig returns true for first zone"), LevelLauncher->GetZoneConfig(LevelState, ZoneConfig));
			TestEqual(TEXT("ZoneConfig populated"), ZoneConfig.TimeTargetSeconds, 120);
		});
		It("Should Return True on second zone", [this]() {
			auto LevelLauncher = NewLauncher();

			FLevelState LevelState(Levels[0], 0, 1);
			FLevelZoneConfig ZoneConfig;

			TestTrue(TEXT("GetZoneConfig returns true for next zone on same level"), LevelLauncher->GetZoneConfig(LevelState, ZoneConfig));
			TestEqual(TEXT("ZoneConfig populated"), ZoneConfig.TimeTargetSeconds, 90);
		});
		It("Should Return True for last zone first level", [this]() {
			auto LevelLauncher = NewLauncher();

			FLevelState LevelState(Levels[0], 0, 2);
			FLevelZoneConfig ZoneConfig;

			TestTrue(TEXT("GetZoneConfig returns true for last zone on same level"), LevelLauncher->GetZoneConfig(LevelState, ZoneConfig));
			TestEqual(TEXT("ZoneConfig populated"), ZoneConfig.TimeTargetSeconds, 60);
		});
		It("Should Return false for non-existent zone on a level", [this]() {
			auto LevelLauncher = NewLauncher();

			FLevelState LevelState(Levels[0], 0, 100);
			FLevelZoneConfig ZoneConfig;

			TestFalse(TEXT("GetZoneConfig returns false for non-existent zone on an existing level"), LevelLauncher->GetZoneConfig(LevelState, ZoneConfig));
		});
		It("Should Return True on first zone of last level", [this]() {
			auto LevelLauncher = NewLauncher();

			FLevelState LevelState(Levels[1], 1, 0);
			FLevelZoneConfig ZoneConfig;

			TestTrue(TEXT("GetZoneConfig returns true for first zone on last level"), LevelLauncher->GetZoneConfig(LevelState, ZoneConfig));
			TestEqual(TEXT("ZoneConfig populated"), ZoneConfig.TimeTargetSeconds, 50);
		});
		It("Should Return True on last zone of last level", [this]() {
			auto LevelLauncher = NewLauncher();

			FLevelState LevelState(Levels[1], 1, 2);
			FLevelZoneConfig ZoneConfig;

			TestTrue(TEXT("GetZoneConfig returns true for last zone on last level"), LevelLauncher->GetZoneConfig(LevelState, ZoneConfig));
			TestEqual(TEXT("ZoneConfig populated"), ZoneConfig.TimeTargetSeconds, 30);
		});
		It("Should Return False for a level index that does not exist", [this]() {
			auto LevelLauncher = NewLauncher();

			FLevelState LevelState(Levels[1], 100);
			FLevelZoneConfig ZoneConfig;

			TestFalse(TEXT("HasNextZone returns false when there is not a level with the given index"), LevelLauncher->GetZoneConfig(LevelState, ZoneConfig));
		});
	});
}


namespace
{
	ULevelLauncher* NewLauncher()
	{
		ULevelLauncher* Launcher = NewObject<ULevelLauncher>();
		Launcher->Init(NewObject<UWorld>(), Levels);

		return Launcher;
	}

	TArray<FLevelConfig> CreateLevels()
	{
		TArray<FLevelConfig> levs
		{
			FLevelConfig
			{
				FName("LevelOne"),
				{
					FLevelZoneConfig
					{
						EAIDifficulty::Easy,
						10,
						1,
						1,
						120
					},
					FLevelZoneConfig
					{
						EAIDifficulty::Normal,
						12,
						1,
						1,
						90
					},
					FLevelZoneConfig
					{
						EAIDifficulty::Hard,
						15,
						1,
						1,
						60
					}
				}
			},
			FLevelConfig
			{
				FName("LevelTwo"),
				{
					FLevelZoneConfig
					{
						EAIDifficulty::Hard,
						10,
						1,
						1,
						50
					},
					FLevelZoneConfig
					{
						EAIDifficulty::Veteran,
						12,
						1,
						1,
						40
					},
					FLevelZoneConfig
					{
						EAIDifficulty::Epic,
						15,
						1,
						1,
						30
					}
				}
			}
		};

		return levs;

	}

}


#endif

And here are a couple of examples of usage

bool ASimpleShooterGameModeBase::NextZone()
{
    ASimpleShooterGameStateBase* ShooterGameStateBase = GetGameState<ASimpleShooterGameStateBase>();
    if (!ShooterGameStateBase)
    {
        UE_LOG(LogTemp, Fatal, TEXT("%s: NextZone - game state is not an instance of ASimpleShooterGameStateBase"), *GetName());
        return false;
    }

    FLevelState NextZoneState;
    if (!LevelLauncher->NextZone(ShooterGameStateBase->GetCurrentLevelState(), &NextZoneState))
    {
        DeleteSavedGame();
        return false;
    }

    ShooterGameStateBase->SetCurrentLevelState(NextZoneState);
    SaveGame();

    return LevelLauncher->LoadZone(NextZoneState);
}

void ASimpleShooterGameModeBase::RestartZone()
{
    ASimpleShooterGameStateBase* ShooterGameStateBase = GetGameState<ASimpleShooterGameStateBase>();
    if (!ShooterGameStateBase)
    {
        UE_LOG(LogTemp, Fatal, TEXT("%s: NextZone - game state is not an instance of ASimpleShooterGameStateBase"), *GetName());
        return;
    }

    LevelLauncher->LoadZone(ShooterGameStateBase->GetCurrentLevelState());
}
void ASimpleShooterGameModeBase::NewGame()
{
    ASimpleShooterGameStateBase* ShooterGameStateBase = GetGameState<ASimpleShooterGameStateBase>();
    if (!ShooterGameStateBase)
    {
        UE_LOG(LogTemp, Fatal, TEXT("%s: NextZone - game state is not an instance of ASimpleShooterGameStateBase"), *GetName());
        return;
    }

    DeleteSavedGame();

    FLevelState FirstZoneState;

    LevelLauncher->FirstZone(&FirstZoneState);
    LevelLauncher->LoadZone(FirstZoneState);
}void ASimpleShooterGameModeBase::NewGame()
{
    ASimpleShooterGameStateBase* ShooterGameStateBase = GetGameState<ASimpleShooterGameStateBase>();
    if (!ShooterGameStateBase)
    {
        UE_LOG(LogTemp, Fatal, TEXT("%s: NextZone - game state is not an instance of ASimpleShooterGameStateBase"), *GetName());
        return;
    }

    DeleteSavedGame();

    FLevelState FirstZoneState;

    LevelLauncher->FirstZone(&FirstZoneState);
    LevelLauncher->LoadZone(FirstZoneState);
}void ASimpleShooterGameModeBase::NewGame()
{
    ASimpleShooterGameStateBase* ShooterGameStateBase = GetGameState<ASimpleShooterGameStateBase>();
    if (!ShooterGameStateBase)
    {
        UE_LOG(LogTemp, Fatal, TEXT("%s: NextZone - game state is not an instance of ASimpleShooterGameStateBase"), *GetName());
        return;
    }

    DeleteSavedGame();

    FLevelState FirstZoneState;

    LevelLauncher->FirstZone(&FirstZoneState);
    LevelLauncher->LoadZone(FirstZoneState);
}void ASimpleShooterGameModeBase::NewGame()
{
    ASimpleShooterGameStateBase* ShooterGameStateBase = GetGameState<ASimpleShooterGameStateBase>();
    if (!ShooterGameStateBase)
    {
        UE_LOG(LogTemp, Fatal, TEXT("%s: NextZone - game state is not an instance of ASimpleShooterGameStateBase"), *GetName());
        return;
    }

    DeleteSavedGame();

    FLevelState FirstZoneState;

    LevelLauncher->FirstZone(&FirstZoneState);
    LevelLauncher->LoadZone(FirstZoneState);
}void ASimpleShooterGameModeBase::NewGame()
{
    ASimpleShooterGameStateBase* ShooterGameStateBase = GetGameState<ASimpleShooterGameStateBase>();
    if (!ShooterGameStateBase)
    {
        UE_LOG(LogTemp, Fatal, TEXT("%s: NextZone - game state is not an instance of ASimpleShooterGameStateBase"), *GetName());
        return;
    }

    DeleteSavedGame();

    FLevelState FirstZoneState;

    LevelLauncher->FirstZone(&FirstZoneState);
    LevelLauncher->LoadZone(FirstZoneState);
}void ASimpleShooterGameModeBase::NewGame()
{
    ASimpleShooterGameStateBase* ShooterGameStateBase = GetGameState<ASimpleShooterGameStateBase>();
    if (!ShooterGameStateBase)
    {
        UE_LOG(LogTemp, Fatal, TEXT("%s: NextZone - game state is not an instance of ASimpleShooterGameStateBase"), *GetName());
        return;
    }

    DeleteSavedGame();

    FLevelState FirstZoneState;

    LevelLauncher->FirstZone(&FirstZoneState);
    LevelLauncher->LoadZone(FirstZoneState);
}
void ASinglePlayerEnemyHuntGameMode::SaveGame()
{
	UE_LOG(LogTemp, Display, TEXT("Saving Game..."));

	ASinglePlayerEnemyHuntGameState* MyGameState = Cast<ASinglePlayerEnemyHuntGameState>(GameState);

	if (!MyGameState)
	{
		UE_LOG(LogTemp, Error, TEXT("ASinglePlayerEnemyHuntGameMode::SaveGame - GameState did not return a ASinglePlayerEnemyHuntGameState instance"));
		return;
	}

	USaveSinglePlayerEnemyHuntGame* SavedGame = SaveGameUtilities::CreateSaveGameInstance<USaveSinglePlayerEnemyHuntGame>();

	if (!SavedGame)
	{
		UE_LOG(LogTemp, Error, TEXT("Unable to create a saved game instance"));
		return;
	}

	SavedGame->SetFromGameState(GetWorld(), *MyGameState);
	SavedGame->Save();

	OnGameSaved.Broadcast();

	UE_LOG(LogTemp, Display, TEXT("Game Saved Successfully"));
}

void ASinglePlayerEnemyHuntGameMode::LoadGame()
{
	if (!DoesSaveGameExist())
	{
		return;
	}

	UE_LOG(LogTemp, Display, TEXT("Loading Game..."));

	ASinglePlayerEnemyHuntGameState* MyGameState = Cast<ASinglePlayerEnemyHuntGameState>(GameState);

	if (!MyGameState)
	{
		UE_LOG(LogTemp, Error, TEXT("ASinglePlayerEnemyHuntGameMode::LoadGame - GameState did not return a ASinglePlayerEnemyHuntGameState instance"));
		return;
	}

	USaveSinglePlayerEnemyHuntGame* LoadedGame = GetSavedGame();

	if (LoadedGame)
	{
		UE_LOG(LogTemp, Display, TEXT("Loading game: Version=%d;SlotIndex=%d; SlotName=%s"), LoadedGame->GetVersion(), LoadedGame->SlotIndex, *LoadedGame->SlotName);

		LevelLauncher->LoadZone(LoadedGame->CurrentLevelState);
	}
}

I finished this back almost a year ago back in January, but finally posted it up on Itch.io:

Privacy & Terms