Goals
Gain more experience with Unreal Engine
UI widgets
Character controllers
2D animation
Damage Types and modifiers
Creating RPG elements
Highlights
Rewarding combat, including a dodge that increases damage
Built a system to create upgrades with little to no coding
Built a system to create new characters and weapons
Standardized stats and modifiers makes it easy to create both player and AI controlled pawns
Status effect system that is easily extendable
Settings menu with volume settings (Master, Music, SFX)
Lessons Learned
Using events is far more efficient than calling functions of the other object
Scope creep needs to be kept to a minimum
Unreal Engine's AI is easier to implement using float vectors rather than a grid
Emulating games based on old hardware (SNES, GB, etc...) is more difficult than making it feel similar
Using orthographic cameras on an angle creates visual issues with 2D assets
Gaps become visible in Paper2D tiles while moving
Z order of objects becomes more complicated than it needs to be
Fixed by making the camera perspective, and making the terrain 3D
Making 3D objects textured pixel perfect while keeping the same pixel to world size is very difficult
All faces on 3D objects were calculated to be the correct size to maintain pixel to world ratio
Having attacks bring characters to a complete stop is more intrusive than it seems initially
Stopping while attacking feels very choppy
Could have characters slide to a stop
Could let characters move during attacks
Not allowing the player to move makes it difficult to dodge enemy attacks
Fixed this issue by having enemies have a small chance to drop healing items
What I’d do differently
Start from a small gameplay loop and build out from there
Write code with events in mind
Design bosses and enemies around a float based engine
Design gameplay around how the engine is meant to be used, then make it feel closer to the retro game inspiration
Use and entirely top down view with orthographic camera, or use a perspective camera with low poly models and textures
Make some sort of tutorial, rather than only having the controls displayed on the main menu
Give melee attacks a character animation with a hitbox rather than spawning a special projectile
Add a short invincibility window after getting hit
Make levels that aren't set up to be procedural
Weapons are divided into 2 categories that each have their own subtype: Ranged and Melee. Both of these subtypes need to handle attacking differently, so they each have their own attack functions.
UCLASS()
class TRIANGLESPIRITS_API AWeapon : public AActor
{
GENERATED_BODY()
public:
AWeapon();
UPROPERTY(EditAnywhere, BlueprintReadWrite)
class TSubclassOf<UDamageType> DamageTypeClass;
UPROPERTY(EditAnywhere, BlueprintReadWrite)
float SpawnOffset;
UPROPERTY(EditAnywhere, BlueprintReadWrite)
bool IsRanged;
UPROPERTY(EditAnywhere, BlueprintReadWrite)
class USceneComponent* SceneRoot;
UPROPERTY(EditAnywhere, BlueprintReadWrite)
class UPaperSprite* WeaponSprite;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Weapon|Skills")
TArray<TSubclassOf<UPassiveSkill>> PassiveSkillPool;
protected:
// Called when the game starts or when spawned
virtual void BeginPlay() override;
public:
// Called every frame
virtual void Tick(float DeltaTime) override;
};
AWeapon::AWeapon()
{
PrimaryActorTick.bCanEverTick = true;
PassiveSkillPool = TArray<TSubclassOf<UPassiveSkill>>();
SceneRoot = CreateDefaultSubobject<USceneComponent>(TEXT("Scene Root"));
RootComponent = SceneRoot;
WeaponSprite = CreateDefaultSubobject<UPaperSprite>(TEXT("Menu Sprite"));
SpawnOffset = 40.0f;
}
// Called when the game starts or when spawned
void AWeapon::BeginPlay()
{
Super::BeginPlay();
}
// Called every frame
void AWeapon::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
}
UCLASS()
class TRIANGLESPIRITS_API AMeleeWeapon : public AWeapon
{
GENERATED_BODY()
public:
AMeleeWeapon();
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Weapon")
TSubclassOf<AMeleeWeaponSwing> WeaponSwingClass;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Weapon|Stats")
FMeleeWeaponStats BaseStats;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Weapon|Stats")
FMeleeWeaponStats ModifiedStats;
protected:
// Called when the game starts or when spawned
virtual void BeginPlay() override;
public:
// Called every frame
virtual void Tick(float DeltaTime) override;
UFUNCTION(BlueprintCallable, BlueprintNativeEvent)
float Swing(const FTransform& offset, bool isCrit);
float Swing_Implementation(const FTransform& offset, bool isCrit);
};
AMeleeWeapon::AMeleeWeapon()
{
PrimaryActorTick.bCanEverTick = true;
}
void AMeleeWeapon::BeginPlay()
{
Super::BeginPlay();
ModifiedStats = BaseStats;
}
void AMeleeWeapon::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
}
float AMeleeWeapon::Swing_Implementation(const FTransform& offset, bool isCrit)
{
AMeleeWeaponSwing* weaponSwing = Cast<AMeleeWeaponSwing>(UGameplayStatics::BeginDeferredActorSpawnFromClass(GetWorld(), WeaponSwingClass, offset, ESpawnActorCollisionHandlingMethod::AlwaysSpawn, this->GetOwner()));
weaponSwing->Damage = isCrit ? ModifiedStats.Damage + ModifiedStats.Damage * ModifiedStats.CriticalDamage : ModifiedStats.Damage;
weaponSwing->Knockback = ModifiedStats.Knockback;
weaponSwing->SetActorScale3D(FVector(ModifiedStats.Reach));
weaponSwing->DamageType = DamageTypeClass;
weaponSwing->SetInstigator(Cast<APawn>(this->GetAttachParentActor()));
UGameplayStatics::FinishSpawningActor(weaponSwing, offset);
return weaponSwing->AttackAnimation->GetFlipbookLength();
}
UCLASS()
class TRIANGLESPIRITS_API ARangedWeapon : public AWeapon
{
GENERATED_BODY()
public:
ARangedWeapon();
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Weapon")
TSubclassOf<AProjectile> ProjectileClass;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Weapon|Stats")
FRangedWeaponStats BaseStats;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Weapon|Stats")
FRangedWeaponStats ModifiedStats;
UFUNCTION(BlueprintCallable, BlueprintNativeEvent)
float Fire(const FTransform& offset, int scalingMod);
float Fire_Implementation(const FTransform& offset, int scalingMod);
protected:
// Called when the game starts or when spawned
virtual void BeginPlay() override;
};
ARangedWeapon::ARangedWeapon()
{
IsRanged = true;
}
float ARangedWeapon::Fire_Implementation(const FTransform& offset, int scalingMod)
{
AProjectile* firedProjectile = Cast<AProjectile>(UGameplayStatics::BeginDeferredActorSpawnFromClass(GetWorld(), ProjectileClass, offset, ESpawnActorCollisionHandlingMethod::AlwaysSpawn, this->GetOwner()));
if (firedProjectile != nullptr)
{
firedProjectile->Damage = ModifiedStats.Damage;
firedProjectile->DamageType = DamageTypeClass;
firedProjectile->SetInstigator(Cast<APawn>(this->GetAttachParentActor()));
UGameplayStatics::FinishSpawningActor(firedProjectile, offset);
}
return 1.0f / ModifiedStats.FireRate;
}
void ARangedWeapon::BeginPlay()
{
ModifiedStats = BaseStats;
}
Each weapon introduces a number of passive skills that can be gained by leveling up. An array of the skills is sored on the weapon object, so they can be added to the total pool on startup. Since these skills may affect different weapons in different ways, there are structures for each weapon type that keep track of the base and current stats of a weapon.