2022
Concept Topdown ARPG game I am currently working on based on Celtic lore. Please note that all art on this page was not made by me. All game design and code, however, are my own.
This Page is split into two parts: the One Page Pitch and some Code Highlights. Feel free to skip to either of them using the buttons below.
Using Epic Games' Gameplay Ability System (GAS), I setup the Base Character class to allow for abilities to easily be granted and activated for any character in the game. Additionally, I implemented Health, Mana, XP, etc for each character.
An example Gameplay Ability to create a fireball in GAS. This blueprint spawns a fireball in front of the player character while also attaching a Damage Calculation payload to it. Once the fireball actor overlaps with a character with the "Enemy" tag, deal damage using the Damage Calculation payload.
And this is the corresponding Fireball Blueprint that is being spawned from the above Gameplay Ability.
Base Attribute Set for Character Stats.
#pragma once #include "CoreMinimal.h" #include "AttributeSet.h" #include "AbilitySystemComponent.h" // Added for our ASC code #include "BaseAttributeSet.generated.h" // Uses macros from AttributeSet.h #define ATTRIBUTE_ACCESSORS(ClassName, PropertyName) \ GAMEPLAYATTRIBUTE_PROPERTY_GETTER(ClassName, PropertyName) \ GAMEPLAYATTRIBUTE_VALUE_GETTER(PropertyName) \ GAMEPLAYATTRIBUTE_VALUE_SETTER(PropertyName) \ GAMEPLAYATTRIBUTE_VALUE_INITTER(PropertyName) /** * */ UCLASS() class MORRIGAN_API UBaseAttributeSet : public UAttributeSet { GENERATED_BODY() public: UBaseAttributeSet(); virtual void PreAttributeChange(const FGameplayAttribute& Attribute, float& NewValue) override; virtual void PostGameplayEffectExecute(const FGameplayEffectModCallbackData& Data) override; virtual void GetLifetimeReplicatedProps(TArray& OutLifetimeProps) const override; // ---------------------------------------------------------------- // HEALTH, STAMINA, ATTACK POWER //----------------------------------------------------------------- // Define health attribute and setup the attribute using our macros UPROPERTY(BlueprintReadOnly, Category = "Attributes", ReplicatedUsing = OnRep_Health) FGameplayAttributeData Health; ATTRIBUTE_ACCESSORS(UBaseAttributeSet, Health); //---- // Define Max health attribute and setup the attribute using our macros UPROPERTY(BlueprintReadOnly, Category = "Attributes", ReplicatedUsing = OnRep_MaxHealth) FGameplayAttributeData MaxHealth; ATTRIBUTE_ACCESSORS(UBaseAttributeSet, MaxHealth); //---- // Define Regen health attribute and setup the attribute using our macros UPROPERTY(BlueprintReadOnly, Category = "Attributes", ReplicatedUsing = OnRep_RegenHealth) FGameplayAttributeData RegenHealth; ATTRIBUTE_ACCESSORS(UBaseAttributeSet, RegenHealth); //---- // Define Mana attribute and setup the attribute using our macros UPROPERTY(BlueprintReadOnly, Category = "Attributes", ReplicatedUsing = OnRep_Mana) FGameplayAttributeData Mana; ATTRIBUTE_ACCESSORS(UBaseAttributeSet, Mana); /// ...
Damage Calculation Implementation
void UBaseAttributeSet::PostGameplayEffectExecute(const FGameplayEffectModCallbackData& Data) { Super::PostGameplayEffectExecute(Data); FGameplayEffectContextHandle Context = Data.EffectSpec.GetContext(); UAbilitySystemComponent* Source = Context.GetOriginalInstigatorAbilitySystemComponent(); const FGameplayTagContainer& SourceTags = *Data.EffectSpec.CapturedSourceTags.GetAggregatedTags(); FGameplayTagContainer SpecAssetTags; Data.EffectSpec.GetAllAssetTags(SpecAssetTags); // Get the Target actor, which should be our owner AActor* TargetActor = nullptr; AController* TargetController = nullptr; AMorriganCharacter* TargetCharacter = nullptr; if (Data.Target.AbilityActorInfo.IsValid() && Data.Target.AbilityActorInfo->AvatarActor.IsValid()) { TargetActor = Data.Target.AbilityActorInfo->AvatarActor.Get(); TargetController = Data.Target.AbilityActorInfo->PlayerController.Get(); TargetCharacter = Cast(TargetActor); } // Get the Source actor AActor* SourceActor = nullptr; AController* SourceController = nullptr; AMorriganCharacter* SourceCharacter = nullptr; if (Source && Source->AbilityActorInfo.IsValid() && Source->AbilityActorInfo->AvatarActor.IsValid()) { SourceActor = Source->AbilityActorInfo->AvatarActor.Get(); SourceController = Source->AbilityActorInfo->PlayerController.Get(); if (SourceController == nullptr && SourceActor != nullptr) { if (APawn* Pawn = Cast (SourceActor)) { SourceController = Pawn->GetController(); } } // Use the controller to find the source pawn if (SourceController) { SourceCharacter = Cast (SourceController->GetPawn()); } else { SourceCharacter = Cast (SourceActor); } // Set the causer actor based on context if it's set if (Context.GetEffectCauser()) { SourceActor = Context.GetEffectCauser(); } } //////////////////////////////////////////////////////// if (Data.EvaluatedData.Attribute == GetAttackPowerAttribute()) { // Store a local copy of the amount of damage done and clear the damage attribute const float LocalDamageDone = GetAttackPower(); SetAttackPower(0.f); if (LocalDamageDone > 0.0f) { // If character was alive before damage is added, handle damage // This prevents damage being added to dead things and replaying death animations bool WasAlive = true; if (TargetCharacter) { WasAlive = TargetCharacter->IsAlive(); } if (!TargetCharacter->IsAlive()) { //UE_LOG(LogTemp, Warning, TEXT("%s() %s is NOT alive when receiving damage"), TEXT(__FUNCTION__), *TargetCharacter->GetName()); } // Apply the health change and then clamp it const float DamageAfterDefense = LocalDamageDone - (LocalDamageDone * GetDefense()); const float NewHealth = GetHealth() - DamageAfterDefense; SetHealth(FMath::Clamp(NewHealth, 0.0f, GetMaxHealth())); if (TargetCharacter && WasAlive) { // TODO Check for HitReact and use that to play a sound of animation for getting hit // Display damage number for the Source player unless it was self damage if (SourceActor != TargetActor) { AMorriganPlayerController* PC = Cast (SourceController); if (PC) { PC->ShowDamageNumber(LocalDamageDone, TargetCharacter); } } // TargetCharacter was alive before this damage and now is dead // Award XP // Dont give XP to self if (!TargetCharacter->IsAlive()) { if (SourceController != TargetController) { // Create a dynamic gameplay effect to give XP to the source UGameplayEffect* GEBounty = NewObject (GetTransientPackage(), FName(TEXT("Bounty"))); GEBounty->DurationPolicy = EGameplayEffectDurationType::Instant; int32 Idx = GEBounty->Modifiers.Num(); GEBounty->Modifiers.SetNum(Idx + 2); FGameplayModifierInfo& InfoGold = GEBounty->Modifiers[Idx]; InfoGold.ModifierMagnitude = FScalableFloat(GetXPBounty()); InfoGold.ModifierOp = EGameplayModOp::Additive; InfoGold.Attribute = UBaseAttributeSet::GetXPAttribute(); // TODO Add gold to bounty Source->ApplyGameplayEffectToSelf(GEBounty, 1.0f, Source->MakeEffectContext()); } } } } } // End Damage else if (Data.EvaluatedData.Attribute == GetHealthAttribute()){ // Handle other health changes. // Health loss should go through Damage. SetHealth(FMath::Clamp(GetHealth(), 0.0f, GetMaxHealth())); } // End Health else if (Data.EvaluatedData.Attribute == GetManaAttribute()){ // Handle mana changes. SetMana(FMath::Clamp(GetMana(), 0.0f, GetMaxMana())); } // End Mana else if (Data.EvaluatedData.Attribute == GetDefenseAttribute()) { SetDefense(FMath::Clamp(GetDefense(), 0.0f, 1.0f)); } // End Defense }
Base Character GAS setup.
#pragma once #include "CoreMinimal.h" #include "GameFramework/Character.h" #include "AbilitySystemInterface.h" // allows us to interface with IAbilitySystemInterface #include#include "BaseCharacter.generated.h" // broadcasting character death DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FCharacterDiedDelegate, AMorriganCharacter*, Character); UCLASS(Blueprintable) class AMorriganCharacter : public ACharacter, public IAbilitySystemInterface { GENERATED_BODY() protected: // Ability System component allows us to use GAS UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = Camera, meta = (AllowPrivateAccess = "true")) class UBaseAbilitySystemComponent* AbilitySystemComponent; // GAS attributes that allow us to have our health, mana, and attack power UPROPERTY() const class UBaseAttributeSet* Attributes; // Called when the game starts or when spawned virtual void BeginPlay() override; // ===================================== // UI // ===================================== UPROPERTY(BlueprintReadWrite, EditAnywhere, Category = "UI") TSubclassOf UIFloatingStatusBarClass; UPROPERTY() class UFloatingHealthWidget* UIFloatingStatusBar; UPROPERTY(BlueprintReadOnly, VisibleAnywhere, Category = "UI") class UWidgetComponent* UIFloatingStatusBarComponent; FDelegateHandle HealthChangedDelegateHandle; // Attribute changed callbacks virtual void HealthChanged(const FOnAttributeChangeData& Data); FGameplayTag DeadTag; FGameplayTag EffectRemoveOnDeathTag; public: AMorriganCharacter(); /** Returns CameraBoom subobject **/ FORCEINLINE class UBoxComponent* GetMeleeBoxHitbox() const { return MeleeBoxHitbox; } // Called every frame. virtual void Tick(float DeltaSeconds) override; /** Returns current health, will be 0 if dead */ UFUNCTION(BlueprintCallable) virtual float GetHealth() const; UFUNCTION(BlueprintCallable) virtual bool IsAlive() const; /** Returns maximum health, health will never be greater than this */ UFUNCTION(BlueprintCallable) virtual float GetMaxHealth() const; UFUNCTION(BlueprintCallable) virtual float GetRegenHealth() const; /** Returns current mana */ UFUNCTION(BlueprintCallable) virtual float GetMana() const; UFUNCTION(BlueprintCallable) virtual float GetRegenMana() const; /** Returns maximum mana, mana will never be greater than this */ UFUNCTION(BlueprintCallable) virtual float GetMaxMana() const; UFUNCTION(BlueprintCallable) virtual float GetDefense() const; UFUNCTION(BlueprintCallable) virtual float GetAttackPower() const; UFUNCTION(BlueprintCallable) virtual int32 GetXPBounty() const; UFUNCTION(BlueprintCallable) virtual int32 GetCharacterLevel() const; UFUNCTION(BlueprintCallable) virtual int32 GetXP() const; // ======================== // DEATH // ======================== UPROPERTY(BlueprintAssignable, Category = "Morrigan|BaseCharacter") FCharacterDiedDelegate OnCharacterDied; // Death Animation UPROPERTY(BlueprintReadWrite, EditAnywhere, Category = "Morrigan|Animation") UAnimMontage* DeathMontage; virtual void Die(); UFUNCTION(BlueprintCallable, Category = "Morrigan|BaseCharacter") virtual void FinishDying(); // ===================================== // ABILIITIES // ===================================== // have to implement to get our specific ASC virtual class UAbilitySystemComponent* GetAbilitySystemComponent() const override; // NOT USED. INITIALIZING IN BeginPlay INSTEAD TO USE DATA TABLE IN ASC // lets initialize our attributes to some default values //virtual void InitializeAttributes(); // lets init our abilities (server side only) virtual void GiveAbilities(); // lets automatically start startup effects like health/mana Regen virtual void AddStartupEffects(); // Removes all abilities. Can only be called by the Server. Removing on the Server will remove from Client too. UFUNCTION(BlueprintCallable) virtual void RemoveAbilities(); virtual void PossessedBy(AController* NewController) override; virtual void OnRep_PlayerState() override; // Default abilities for this Character. These will be removed on Character death and regiven if Character respawns. UPROPERTY(BlueprintReadOnly, EditAnywhere, Category = "Ability System | Passive Abilities") TArray > PassiveAbilities; // NOTE: primary ability defaulted to first ability UPROPERTY(BlueprintReadOnly, EditAnywhere, Category = "Ability System | Activatable Abilities") TArray > ActivatableAbilities; UPROPERTY(BlueprintReadOnly, EditAnywhere, Category = "Ability System | Passive Effects") TArray > StartupEffects; private: /** Top down camera */ UPROPERTY(VisibleAnywhere, BlueprintReadOnly, meta = (AllowPrivateAccess = "true")) class UBoxComponent* MeleeBoxHitbox; };
I defined two behavior trees for Morrigan (the main ally) and enemy AI. These trees handle targeting an enemy, activating gameplay abilities, and following the player.
Basic behavior tree for the main ally, Morrigan. She has two modes: Melee and Projectile Mode. In Projectile Mode, she can kite enemies. Additionally, this behavior tree uses EQS Querying to find the closest enemy.
I use the Unreal Engine standard NavMesh for path finding and generating walkable terrain. Additionally, I enable Reciprocal Velocity Obstacles (RVO) Avoidance to help prevent characters colliding with one another when trying to path around them.
One of the difficulties of developing a Topdown ARPG like Diablo is handling the overloaded Left click button. It needs to handle both moving the player to a location and also handle attacking if the player clicks on an enemy or destructible object. My implementation is shown in the code snippet below. It involves using a trace channel specifically for "walkable" terrain and another trace channel for all clickable characters. This way, every time the player does a left mouse click, I can use the trace channel to filter out all other objects so I am always choosing a walkable location or character to click on. Additionally, I am doing some basic checks for minimum distance traveled and confirming we have a target we are hovering over.
Player Controller snippet
void AMorriganPlayerController::MoveToMouseCursor() { // Trace to see what is under the mouse cursor FHitResult Hit; GetHitResultUnderCursor(ECC_Walkable, false, Hit); if (Hit.bBlockingHit) { if (UNavigationSystemV1* NavSys = FNavigationSystem::GetCurrent(GetWorld())) { // use NavSys to access the navigation system functionality FNavLocation ClosestNavPoint; if (bool SuccessfullyFoundPoint = NavSys->ProjectPointToNavigation(Hit.ImpactPoint, ClosestNavPoint)) { const FVector ClosestLocation = ClosestNavPoint.Location; UE_LOG(LogTemp, Warning, TEXT("%s() Original Point %s projected to %s."), *FString(__FUNCTION__), *Hit.ImpactPoint.ToCompactString(), *ClosestLocation.ToCompactString()); SetNewMoveDestination(ClosestLocation); }else { // Couldn't find closest point on Nav, try to go to ImpactPoint UE_LOG(LogTemp, Warning, TEXT("%s() Could not project to navmesh."), *FString(__FUNCTION__)); SetNewMoveDestination(Hit.ImpactPoint); } } } } void AMorriganPlayerController::SetNewMoveDestination(const FVector DestLocation) { APlayerCharacter* PlayerChar = Cast (GetCharacter()); if (!PlayerChar) { return; } UAbilitySystemComponent* ASC = PlayerChar->GetAbilitySystemComponent(); if (!ASC) { return; } // Setup cancel tags FName MoveCancelTag = FName(TEXT("Ability.MovementCancel")); const FGameplayTagContainer CancelWithTags = FGameplayTagContainer(FGameplayTag::RequestGameplayTag(MoveCancelTag)); if (APawn* const MyPawn = GetPawn()) { if (TargetActor != nullptr) { //Get within Attack range of target actor const float AttackRange = PlayerChar->GetAbilityMinRange(PlayerChar->PrimaryAbility); if (AttackRange < 0) { // This ability has no min attack range, just cast ability // UE_LOG(LogTemp, Error, TEXT("%s() Attacking TargetActor."), *FString(__FUNCTION__)); AttackTarget(TargetActor); }else { // UE_LOG(LogTemp, Error, TEXT("%s() Moving to TargetActor."), *FString(__FUNCTION__)); MoveToActor(TargetActor, AttackRange, true, true, true, DefaultNavigationFilterClass, true); } }else { // We need to issue move command only if far enough in order for walk animation to play correctly float const Distance = FVector::Dist(DestLocation, MyPawn->GetActorLocation()); if ((Distance > 10.0f)) { // Cancel Any Abilities that can be cancelled with movement ASC->CancelAbilities(&CancelWithTags); FAIMoveRequest MoveReq(DestLocation); MoveReq.SetUsePathfinding(true); MoveReq.SetAllowPartialPath(true); UE_LOG(LogTemp, Error, TEXT("%s() Move to Location."), *FString(__FUNCTION__)); MoveTo(MoveReq); } } } }
Destroy hordes of monsters with your companion Morrigan in search for Gwydion, the Trickster Deity. Grow in power as you aide Morrigan in reclaiming her stolen power from Gwydion using ancient powers lost in Celtic Lore.
Morrigan is an ally you will befriend and slowly grow to trust throughout your adventure. While she is an NPC, you can help shape her play style to what suits you. Whether you need a front line tank or a back row mage, she adapt her wide range of skills to better help you turn the tide of battle.
Top down ARPG Dungeon Crawler
Pitch: Diablo meets Hades
by Csyeung on Deviantart
Goddess of Death and Destiny, Goddess of Fate, the Phantom Queen, the Seductress, the Old Hag, the Raven, the Triple Goddess. She is all of these things.
And she needs your help.
Morrigan is a powerful ally on your journey. She grows in power along side your hero. Morrigan has her own skill tree and level and is integral to your success. Morrigan can call forth her ravens to her side to aide her, conjure fantastical illusions and phantoms, and even utilize mental magic to confuse and terrify the enemy. You choose what powers she focuses on as she fights to reclaim her full power once more.
by Wizards of the Coast
Shutterstock
by Wizards of the Coast
Born of the Trees, this Trickster God has stolen the mantle of power from Morrigan. Renowned for his Magic, Cunningness, and Strength as a Warrior, he is not one to take lightly.
Gwydion has many who follow his command. Creatures like the Fey of the Woods are cunning, numerous, and possess a power that is not to be underestimated.