About Last Knight

Josh Bouscher

--

Last Knight is the name I’ve given to my Unreal project. It’s a bit of a play on “Final Fantasy”. As described in my Path to Implementation, I considered Spawners to be the first order of business. I have an initial pass implemented and functional.

I’m not sure how useful (or interesting) the actual implementation details are, but I wanted to share the API that I’ve established thus far.

First, there is UKnightSpawnerSubsystem, a tickable GameInstanceSubsystem. The subsystem expects individual spawners to register with it so that it can manage triggering them. It ticks because any spawn can happen on a delay. Even the base event to trigger a spawn (which happens during BeginPlay) with no delay will end up executing on the next tick to ensure everything that happened thanks to BeginPlay is finished.

USTRUCT()
struct FKnightPendingSpawn
{
GENERATED_BODY()

UPROPERTY()
AKnightSpawner* Spawner;

UPROPERTY()
UKnightSpawnTrigger* Trigger;

UPROPERTY()
float SpawnDelay;
};

/**
* UKnightSpawnerSubsystem
* Manages all of the game's spawners.
*/
UCLASS()
class LASTKNIGHT_API UKnightSpawnerSubsystem : public UGameInstanceSubsystem, public FTickableGameObject
{
GENERATED_BODY()

public:

// Attempts to trigger all spawners with the given parameters, unless the Spawner
// field is set, in which case only that one specific Spawner will be tried.
void TryTriggerSpawners(const FKnightSpawnTriggerParams& Params);

UFUNCTION(BlueprintCallable)
void TryTriggerSpawnersForEvent(const FGameplayTag& EventTag);
UFUNCTION(BlueprintCallable)
void TryTriggerSpawnerForEvent(AKnightSpawner* Spawner, const FGameplayTag& EventTag);

void RegisterSpawner(AKnightSpawner* Spawner);
void AddPendingSpawn(const FKnightPendingSpawn& PendingSpawn) { PendingSpawns.Add(PendingSpawn); }

/*
* FTickableGameObject
*/
virtual TStatId GetStatId() const override { return TStatId(); }
virtual void Tick(float DeltaTime) override;

protected:

TArray<AKnightSpawner*> RegisteredSpawners;
TArray<AKnightSpawner*> NewlyRegisteredSpawners;
TArray<FKnightPendingSpawn> PendingSpawns;

};

AKnightSpawner is an abstract base class. So far there is only one concrete version, AKnightWaveSpawner. Since I am basing this on Hades, I expect to have to summon waves of enemies during battle. That seemed like the most complicated thing to do, so I ran with that first.

You can get a very basic spawner out of the wave spawner by implementing just 1 wave. It’s quite possible all of the tactical spawners will use this class. However, the strategy side of the game seems (without deep exploration at this point) like it could use an entirely different base spawner.

/**
* AKnightSpawner
* Native base class for all spawners.
* Not an acceptable thing to Blueprint as it does not have
* specifications for what or where to spawn.
*/
UCLASS(NotBlueprintable, Abstract)
class LASTKNIGHT_API AKnightSpawner : public AActor
{
GENERATED_BODY()

public:
// Sets default values for this actor's properties
AKnightSpawner();

// Iterate the spawner's triggers and check to see if they are triggered by the parameters
// by calling ShouldTriggerSpawn(Params) on it. When there's a match, send a PendingSpawn
// to the KnightSpawnerSubsystem->AddPendingSpawn. That will call SpawnerTriggered when the delay is over.
// (Always happens via the subsystem Tick so 0 delay actually won't happen until the next tick.)
virtual void TryTriggerSpawner(const FKnightSpawnTriggerParams& Params) { }

// Determine what needs to be spawned and spawn it! (You could make a BlueprintCallable function if desired.)
virtual void SpawnerTriggered(UKnightSpawnTrigger* TriggeredBy) { }

protected:
// Called when the game starts or when spawned
virtual void BeginPlay() override;

UKnightSpawnerSubsystem* GetSubsystem() const;

public:
// Called every frame
virtual void Tick(float DeltaTime) override;
};

USTRUCT(BlueprintType)
struct LASTKNIGHT_API FKnightWaveSpawnerParams
{
GENERATED_BODY()

UPROPERTY(EditAnywhere, Instanced)
TArray<UKnightSpawnTrigger*> SpawnTriggers;

UPROPERTY(EditAnywhere)
TArray<UKnightSpawnDef*> SpawnDefs;

UPROPERTY(EditAnywhere, Instanced)
UKnightSpawnLocation* SpawnLoc;

// marked true when the wave triggers successfully so it does not repeat
UPROPERTY()
bool bSpawned = false;
};

USTRUCT()
struct LASTKNIGHT_API FKnightWaveSpawnedData
{
GENERATED_BODY()

UPROPERTY()
int WaveIndex = INDEX_NONE;
UPROPERTY()
TArray<TWeakObjectPtr<AActor>> SpawnedActors;
};

UCLASS(Blueprintable)
class LASTKNIGHT_API AKnightWaveSpawner : public AKnightSpawner
{
GENERATED_BODY()

public:

virtual void TryTriggerSpawner(const FKnightSpawnTriggerParams& Params) override;
virtual void SpawnerTriggered(UKnightSpawnTrigger* TriggeredBy) override;

protected:

UPROPERTY(EditAnywhere)
TArray<FKnightWaveSpawnerParams> SpawnerWaves;

// If false, then any wave that hasn't triggered will be processed for any trigger.
// Otherwise, only the first wave that hasn't triggered will be processed, and therefore
// it must complete before the next wave could be triggered.
// A wave is marked as "spawned" when it is triggered, even if the spawn hasn't actually happened yet
// (due to a timed delay).
UPROPERTY(EditAnywhere)
bool bSequentialWaves = true;

UPROPERTY()
TArray<FKnightWaveSpawnedData> WaveSpawnedData;
};

The interesting stuff is in the wave params. Each wave specifies its own triggers, spawn definitions, and spawn location.

Triggers

The idea behind the trigger implementation is that gameplay events (either as part of a systemic implementation or level scripting) would send out triggers to all spawners (using an FGameplayTag to describe the event). Internally, wave spawners will also trigger with a KillCount specifier to indicate how many in the current wave have been eliminated.

Triggers would be able to do things like have a random roll, so a fishing point spawner may have a BeginPlay trigger with a 10% (or whatever) chance. When TryTriggerSpawner is called, the roll is made and the trigger is successful or not. I haven’t yet gotten beyond the basic MatchTagAndKills trigger, but it should be easily extensible.

USTRUCT(BlueprintType)
struct LASTKNIGHT_API FKnightSpawnTriggerParams
{
GENERATED_BODY()

UPROPERTY()
FGameplayTag ExactMatchTag;

UPROPERTY()
int KillCount = 0;

UPROPERTY()
AKnightSpawner* Spawner = nullptr;

UPROPERTY()
int WaveIndex = INDEX_NONE;
};

/**
* UKnightSpawnTrigger
* Abstract base class for all spawn trigger types.
*/
UCLASS(Abstract, EditInlineNew)
class LASTKNIGHT_API UKnightSpawnTrigger : public UObject
{
GENERATED_BODY()

public:

virtual bool ShouldTriggerSpawn(const FKnightSpawnTriggerParams& Params) { return false; }

UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Trigger Rules")
float SpawnDelay = 0.0f;
};

/**
* UKnightSpawnTrigger_MatchTagAndKills
* Basic trigger to spawn from any "event" by matching the gameplay tag.
* Leave KillsRequired at 0 and only the tag match will matter.
*/
UCLASS()
class LASTKNIGHT_API UKnightSpawnTrigger_MatchTagAndKills : public UKnightSpawnTrigger
{
GENERATED_BODY()

public:

virtual bool ShouldTriggerSpawn(const FKnightSpawnTriggerParams& Params) override;

protected:

UPROPERTY(EditAnywhere, Category="Trigger Rules")
FGameplayTag ExactMatchTag;

// -1 means ALL of the previous wave must be killed
// 0 would mean this can spawn regardless of previous number killed
// any other number requires at LEAST that many to be killed (could be more, depending on timing)
UPROPERTY(EditAnywhere, Category="Trigger Rules")
int KillsRequired = 0;
};

Spawn Definitions

The spawn definitions are the thing I’ve dived into the least so far, but I’m hopeful the API I’ve setup will work for whatever I need. The definition hides the entirety of what is being spawned from the rest of the spawner system. Everything passes around AActor* and is oblivious.

Currently, I have one definition that points at a single character blueprint. A definition could point at multiple blueprints, choosing randomly, or choosing based on what’s already been spawned, or based on difficulty, and so on.

UCLASS(Abstract)
class LASTKNIGHT_API UKnightSpawnDef : public UDataAsset
{
GENERATED_BODY()

public:

virtual void GetActorsToSpawn(TArray<TSubclassOf<AActor>>& ActorsToSpawn);
};

UCLASS()
class LASTKNIGHT_API UKnightSpawnDef_SpecificCharacter : public UKnightSpawnDef
{
GENERATED_BODY()

public:

virtual void GetActorsToSpawn(TArray<TSubclassOf<AActor>>& ActorsToSpawn) override;

protected:

UPROPERTY(EditAnywhere)
TSubclassOf<ACharacter> CharacterBlueprint;
};

Location, Location, Location

The last little piece (for now) is the spawn location. The location knows the spawner that was triggered, and which trigger did it, as well as what actors are about to be spawned. The most basic version simply uses the spawner’s world transform as the spawn transform. I have also implemented a FixedSpawnPoint that references an array of spawn points (a custom class very similar to PlayerStart) and will pick from them at random (or not). It fills in the list of spawn transforms to match the number of things being spawned.

Other location types that spring to mind are “within volume” or “within some radius from the player” or “offscreen but nearby”.

/**
* UKnightSpawnLocation
* Uses the location of the spawner as the spawn location.
*/
UCLASS(EditInlineNew)
class LASTKNIGHT_API UKnightSpawnLocation : public UObject
{
GENERATED_BODY()

public:

virtual void GetSpawnTransforms(const AKnightSpawner* FromSpawner, const UKnightSpawnTrigger* FromTrigger, const TArray<TSubclassOf<AActor>> ActorsToSpawn, TArray<FTransform>& OutTransforms);
};

/**
* UKnightSpawnFixedLocation
* Uses the configured SpawnPoints, in order, as spawn locations.
* If there are not enough points for the number of actors, the last position is repeated.
* Using bRandom will shuffle the SpawnPoints every time GetSpawnTransforms is called.
* The original order of points is preserved.
*/
UCLASS()
class LASTKNIGHT_API UKnightSpawnFixedLocation : public UKnightSpawnLocation
{
GENERATED_BODY()

public:

virtual void GetSpawnTransforms(const AKnightSpawner* FromSpawner, const UKnightSpawnTrigger* FromTrigger, const TArray<TSubclassOf<AActor>> ActorsToSpawn, TArray<FTransform>& OutTransforms) override;

UPROPERTY(EditAnywhere, BlueprintReadWrite)
TArray<AKnightFixedSpawnPoint*> SpawnPoints;

UPROPERTY(EditAnywhere, BlueprintReadWrite)
bool bRandom = false;
};

Next Steps

The next piece is to implement an alive/dead status along with a console command to kill NPCs so I can finalize the wave spawner’s kill count triggers.

That was actually supposed to be step 2, but I need to do that first and then come back to what was step 1: implementing interaction.

The part after that is getting room teleports to work, which I’ve actually already done, although the “next map” is currently hard-coded. So this step will be about getting the logic in place for randomly choosing the next rooms.

I’ve actually made more progress than I thought when I look over these next steps, so I feel like January was pretty successful for me.

--

--

Josh Bouscher
Josh Bouscher

Written by Josh Bouscher

23+ years of video game engineering experience. Looking to share my experiences and learn from others.

No responses yet