EnemyWraithCharacter 실재 게임에서 동일한 기능을 사용한다. 예를 들자면, 공격 및 데미지 입기, 달리기 등과 같은 기능들을 동일하게 사용한다. 이를 베이스클래스에 기능을 구현하고, 캐릭터와 적은 상속을 통해 클래스를 구현한다면 좀더 편하게 개발을 할 수 있다.
필요한 함수와 변수들을 상속시키기 위한 베이스 클래스를 먼저 생성해야 한다.
먼저 Character 기반 BaseCharacter 클래스를 생성한다.


코드를 작성하기 전에 WraithCharacter 클래스와 Enemy 클래스가 보유하고 있는 함수와 변수를 확인해야 한다.
WraithCharacter 클래스의 경우

  • SetWeaponCollisionEnabled(...)
  • Attack(...)
  • PlayAttackMontage(...)
  • CanAttack(...)
  • AttackEnd(...)
  • Eqquippedweapon
  • AttackMontage

Enemy 클래스의 경우

  • Die(...)
  • PlayHitReactMontage(...)
  • GetHit(...)
  • DiretionalHitReact(...)
  • TakeDamage(...) (WraithCharacter와 기능이 완전히 다를 예정이므로 따로 수정x)
  • Attributes
  • HitReactMontage
  • DeathMontage
  • HitSound
  • HitParticles
    를 보유하고 있고, BaseCharacter 클래스는 두 클래스에 있는 함수와 변수 모두 필요하다.

먼저 BaseCharacter 를 상속받을 WraithCharcter.h 을 수정한다.

  • WraithCharacter.h
...
#include "Basecharacter.h:
...
UCLASS()
class STUDY_API AWraithCharacter : public ABaseCharacter
{
	GENERAGED_BODY();
    ...
}
...
  1. BaseCharacter.h 헤더파일을 include 한다.
  2. BaseCharacter 를 상속받을 것이므로 ACharacter -> ABaseCharacter 로 변경한다.

끝났다면 Enemy 에도 BaseCharacter 헤더파일을 include 시켜준다.

  • Enemy.h
// Enemy.h
...
// #include "GameFamework/Character.h"
#include "Characters/BaseCharacter.h"
...
UCLASS()
class STUDY_API AEnemy : public ABaseCharacter
{
	GENERATED_BODY()
    ...
}
  1. BaseCharacter.h 헤더파일을 include 한다.
  2. BaseCharacter 를 상속받을 것이므로 ACharacter -> ABaseCharacter 로 변경한다.


    이제 공통적으로 사용되는 함수와 변수를 복사해서 옮기고(상속받는 케이스이므로 공용으로 몇몇 변수는 protected 섹션으로 옮겨야함), 함수의 경우 구현부까지 복사해서 옮긴다.
  • BaseCharacter.h
// BaseCharacter.h
...
#include "Interfaces/HitInterface.h"
...
class AWeapon;
class UAnimMontage;
class UAttributeComponent;
...

 UCLASS()
 Class STUDY_API ABaseCharacter : public ACharacter, public IHitInterface
public:
	...
    UFUNCION(BlueprintCallable)
    void SetWeaponCollision(ECollisionEnabled::Type CollisionEnabled)
protected:

	...
    /**
    * 클래스
    */
    // 장착중인 무기
    UPROPERTY(VisibleAnywhere, Category = "Weapon")
    AWeapon* EquppedWeapon;
    
    
    /**
    * 함수
    */
    
    // 공격 함수
    virtual void Attack();
    
     // 공격 가능 여부 함수
    vitual bool CanAttack();
    
    // AnimNotify_AttackEnd 실행시 UnOccupied 상태로 변경
    UFUNCTION(BlueprintCallable)
    virtual void AttackEnd();
    
    /**
    * Montage 재생 함수
    */
    
    // AttackMontage 재생 함수
    virtual void PlayAttackMontage();
    
    // DeathMontage 재생 함수
    virtual void Die();
    
    // HitReactMontage 재생 함수
    void PlayHitReactMontage(const FName& SectionName);
    
    // 오류때문에 구현 안해둠
    // DirectionalHitReact 재생 함수
    void DirectionalHitReact(const FVector& ImpactPoint);
    
    
    /**
    * 변수
    */
    
    // 공격 애니메이션 몽타주
    UPROPERTY(EditDefaultsOnly, Category = "Montages")
    UAnimMontage* AttackMontage; 
    
    // 피격시 반응 애니메이션 몽타주
    UPROPERTY(EditDefaultsOnly, Category = "Montages")
    UAnimMontage* HitReactMontage;
    
    // 사망 애니메이션 몽타주
    UPROPERTY(EditDefaultsOnly, Category = "Montages")
    UAnimMontage* DeathMontage;
    
    /**
    *
    */ 컴포넌트
    
    // Atrributes
    URPOPERTY(VisibleAnywhere, BlueprintReadOnly)
    UAttributeComonent* Attributes;
    
    // 피격 사운드
    UPROPERTY(EditAnywhere, Category = "Sounds")
    USoundBase* HitSound;
    
    // 피격시 visual effects
    UPROPERTY(EditAnywhere, Category = "VisualEffects")
    UParticleSystem* HitParticles;
    
private:
	...
  • BaseCharacter.cpp
...
#include "Components/BoxComponent.h"
#include "Items/Weapons/Weapon."
#include "Components/AttributeComponent.h"
...
// BaseCharacter.cpp
...

ABaseCharacter::ABaseCharacter()
{
	Attributes = CreateDefaultSubobject<UAttributeComponent>(TEXT("Attributes"));
}

// 무기 충돌 설정 함수
void ABaseCharacter::SetWeaponCollision(ECollisionEnabled:Type CollisionEnabled)
{
	if(EquippedWeapon && EqquippedWeapon->GetWeaponBox())
    {
    	EqquippedWeapon->GetWeaponBox()->SetCollisionEnabled(CollisionEnabled);
        EquippedWeapon->IgnoreActors.Empty();
    }
}

// 공격 함수
void ABaseCharacter::Attack()
{
}

// AttackMontage 재생 함수
void ABaseCharacter::PlayAttackMontage()
{
}  

// 공격 가능 여부 함수
bool ABaseCharacter::CanAttack()
{
	return false;
}

// AnimNotify_AttackEnd 실행시 UnOccupied 상태로 변경
void ABaseCharacter::AttackEnd()
{
}

// DeathMontage 재생 함수
void ABaseCharacter::Die()
{
}

// HitReacMontage 재생 함수
void ABaseCharacter::PlayHitReactMontage(const FName& SectionName)
{
	UAnimInstance* AnimInstance = GetMEsh()->GetAnimInstance();
    if(AnimInstance)
    {
    	AnimInstance->Montage_Play(SectionName);
        AnimInstance->Montage_JumpToSection(SectionName, HitReactMontage);
    }
}

// DirectionalHitReact 재생 함수, 오류때문에 구현 안해둠
{
	// 추후 오류 해결시 코드작성
}
  • WraithCharacter.h
// WraithCharacter.h
...
protected:
	...
	// 공격 함수
    virtual void Attack() override;
    
    // AttackMontage 재생 함수
    virtual void PlayAttackMontage() override;
    
    // 공격 가능 여부 함수
    virtual bool CanAttack() override;
    
    // AnimNotify_AttackEnd 실행시 UnOccupied 상태로 변경
    // 상속받은 형태이므로 UFUNCTION 매크로 사용 안됨
    virtual void AttackEnd() override;
    
    ...
  • Enemy.h
// Enemy.h
...
// #include "Interfaces/HitInterface.h" 상속받으므로 필요없음
...

UCLASS()
Class STUDY_API AEnemy : public ABaseCharacter//, public IHitInterface 상속받으므로 필요없음
protected:
	...
	// DeathMontage 재생 함수
	virtual void Die() override;

중간중간 컴파일하면서 진행하면 좀더 안전하게 코드를 옮길 수 있다.

BP_Enemy 토대의 다양한 Enemy 생성 및 무기 추가

Paladin 폴더를 생성하고, 기존의 BP_Enemy 의 자식클래스와 기존에 생성해둔 애니메이션 폴더 및 애니메이션 블루프린트를 Paladin 폴더로 옮긴다.
BP_Paladin 을 방패와 검을 든 적으로 만들고 싶다.
먼저 적당한 방패를 LeftHandSocket 을 추가하고 idle 애니메이션에 미리보기로 적용시켜 적당히 위치를 잡아준다.
idle 외에도 공격 애니메이션을 재생시키고, key 값을 조절하여 자연스러운 움직임이 나타나도록 조정한다.
BP_Paladin 으로 돌아와서, 방패를 부착할 Static Mesh 를 생성하고, 방패 메쉬를 할당한다.
Construction Script 탭에서 ShieldMesh 노드를 끌어와 Attach Component To Component 노드의 TargetParent 핀에 각각 연결시켜준다.
ShieldTransform 을 (0, 0, 0)으로 설정해주고 컴파일하면 방패를 제대로 들고 있는 애니메이션이 재생될 것이다.
마지막으로 Collision 을 살펴보면 기본적으로 BlockAllDynimic 으로 설정되어 있다. 이 설정을 그대로 두면 방패와 BP_Paladin 이 충돌을 일으켜 제대로 작동하지 않는다. 그러므로 NoCollision 으로 설정을 변경시킨다.
실행하면 BP_Paladin 이 정상적으로 방패를 들고 움직이는 것을 확인할 수 있다.


이제 무기를 추가해줄 것이다.
먼저 무기를 장착할 오른손에 RightHandSocket 을 생성해주고, 에셋 프리뷰를 통해 소켓의 위치를 조정해준다.
필요에 따라 key 값을 조정해 애니메이션이 자연스럽게 재생되도록 수정한다.
코드를 수정하여 무기를 장착시켜준다.

  • Enemy.h
// Enemy.h
...
private:
	...
	// 장착할 무기, AWeapon 타입 파생클래스만 할당 가능
    UPROPERTY(EditAnywhere)
    TSubClassOf<class AWeapon> WeaponClass;
  • Enemy.cpp
// Enemy.cpp
...
#include "Items/Weapons/Weapon.h
...
void AEnemy::BeginPlay()
{
	Super::BeginPlay();
    
    ...
    UWorld* World = GetWorld();
    if(World && WeaponClass)
    {
    	// SpawnActor로 Weapon 스폰후, DefaultWeapon을 장착, 장학한 무기를 DefaultWeaon으로 할당 
    	AWeapon* DefaultWeapon = World->SpawnActor<AWeapon>(WeaponClass);
        DefaultWeapon->EquipWeapon(GetMesh(), FName("RightHandSocket"), this, this);
        EquippedWeapon = DefaultWeapon;
    }
	...
}

BP_Paladin 에서 WeaponClass 를 사용할 BP_Weapon1 으로 설정해준다.
동일하게 mesh 에 대한 충돌을 방지하기 위해 Collision 설정을 변경해야 한다.
meshItem 클래스에서 상속받은 것이므로 Item 클래스에서 코드를 수정한다.

  • Item.cpp
...
AItem::AItem()
{
	...
    ItemMesh->SetCollisionResponseToAllChannels(ECollisionResponse::ECR_Ignore);
    ItemMesh->SetCollisionEnabled(ECollisionEnabled::NoCollsion);
    ...
}

실행하면 정상적으로 작동되는 것을 확인할 수 있다.


두가지 문제점이 발생하는데, 첫번째는 BP_Paladin 처치시 무기만 남아있는 것이고, 두번재는 게임 시작시 BP_Paladin 이 EquipWeapon에 의해 무기 장착 사운드가 재생되는 것이다.
먼저 적 처치시 무기도 동일하게 없애야한다.

  • Weapon.h
...
protected:
	...
    // Enemy 처치시 무기 파괴(Actor에 있던 함수 override)
    virtual void Destroyed() override;
    ...
  • Weapon.cpp
// Weapon.cpp
...
void AEnemy::Destroyed()
{
	if(EquippedWeapon)
    {
    	EquippedWeapon->Destroy();
    }
}
...

실행시 정상적으로 무기가 같이 파괴되는 것을 확인할 수 있다.


이제 공격 애니메이션 몽타주를 만들 차례이다.
먼저 AM_Attack 애니메이션 몽타주를 생성하고
다운로드해둔 공격 애니메이션 에셋을 슬롯에 추가준 뒤, Montage Section 을 지정해준다.
애니메이션이 연속으로 재생되는 것을 방지하기 위해 우측 Montage Section 에서 Clear 를 눌러주는 것도 잊지 말아야 한다.
블렌딩은 하지 않을 것이므로 좌측의 Asset Details 탭에서 Blend Time 은 0으로 설정해둔다.
이제 BP_PaladinAM_Attack 을 적용시킨다.
BaseCharacter 클래스로부터 상속받은 Enemy 클래스는 Attack() 함수를 재정의하여 사용할 수 있다.

  • Enemy.h
// Enemy.h
...
protected:
	...
    // 공격 함수 override
    virtual void Attack() override;
    
    /**
	* Montage 재생 함수
	*/
	// AttackMontage 재생 함수
	virtual void PlayAttackMontage() override;
	...
  • Enemy.cpp
// Enemy.cpp
...

// 공격 함수 override
void AEnemy::Attack()
{
	Super::Attack();
    
	PlayAttackMontage();
}

// AttackMontage 재생 함수, Super키워드 사용으로 인해 따로 UAnimMontage* AttackMontage 샏성할 필요 없음
void AEnemy::PlayAttackMontage()
{
	Super::PlayAttackMontage();
    
	UAnimInstance* AnimInstance = GetMesh()->GetAnimInstance();
    if(AnimInstance && AttackMontage)
    {
    	AnimInstance->Montage_Play(AttackMontage);
        const int32 RandomSection = FMath::RandRange(0, 2);
        FName SectionName = FName();
        switch(RandomSection)
        {
        	case 0:
            	SectionName = FName("Attack1");
                break;
           	case 1:
            	SectionName = FName("Attack2");
                break;
            case 2:
            	SectionName = FName("Attack3");
                break;
            default:
            	break;
        }
        AnimInstance->Montage_JumpToSection(SectionName, AttackMontage);
    }
}

// 반경내 CombatTarget 유무 확인
void AEnemy::CheckCombatTarget()
{	
	// CombatTarget이 CombatRadius 외부에 위치할 경우
	if(...) { ... }
    // CombatTarget이 AttackRadius 외부에 위치할 경우, Tick함수 호출이므로 한번만 EnemyState 변경하도록 조건설정
    else if(...) { ... }
    // CombatTarget이 AttackRadius 내부에 위치할 경우, Tick함수 호출이므로 한번만 EnemyState 변경하도록 조건설정
    else if (InTargetRange(CombatTarget, AttackRadius) && EnemyState != EEnemyStates::EES_Attacking)
	{
		EnemyState = EEnemyStates::EES_Attacking;
		// TODO : AttackMontage 추가
        Attack();
		UE_LOG(LogTemp, Warning, TEXT("Attack target"));
	}
}

실행하면 BP_Paladin 이 공격하는 모습을 확인할 수 있다.
BP_paladin 이 공격하기 위해 너무 가까이 붙는것 같으므로 약간의 코드수정을 진행한다.

  • Enemy.cpp
// Enemy.cpp
...
void AEnemy::MoveToTarget(AActor* Target)
{
	...
    MoveRequest.SetAcceptanceRadius(60.f);
    ...
}

실행시 적당한 거리에서 공격하는 모습을 확인할 수 있다.


여기에도 문제가 있는데 BP_Paladin 이 공격하자마자 살짝 벗어나면 버벅거리듯이 공격 애니메이션을 다시 재생한다는 것이다. 지금까지 진행한 걸로 코드가 다소 난잡해졌으므로 전체적으로 리토터링을 진행하며 구현할 것이다(CheckCombatTarget()에 있는 두번째 else if 리팩토링때).
1. CharacterTypes 클래스를 먼저 정리할 것이다.
기존의 PlayAttackMontage() 함수는 FMath::RangRange() 를 통해 랜덤한 정수값을 얻고, switch 를 통해 정수값에 따라 재생할 Montage Section 을 따로 변수로 지정해 할당해준 뒤, Montage_JumpToSetdion 을 통해 재생시켰다.
하지만 적의 공격 애니메이션 개수가 변할 때마다 코드를 일일히 수정해야 되는 문제점이 발생한다.
그렇기에 EDeathPoase 는 애니메이션에만 집중하도록 Alive 를 제거하고, EEnemyState 에서 캐릭터의 상태를 관리하도록 수정할 것이다.

  • CharacterTypes.h
    EES_Dead 및 EES_Engaged 추가
...
// Enemy DeathAnimation에 따른 DeadAnimation
UENUM(BlueprintType)
enum class EDeathPose : uint8
{
	EDP_Death1 UMETA(DisplayName = "Alive"),
	EDP_Death1 UMETA(DisplayName = "Death1"),
	EDP_Death2 UMETA(DisplayName = "Death2"),
	EDP_Death3 UMETA(DisplayName = "Death3"),
	EDP_Death4 UMETA(DisplayName = "Death4"),
	EDP_Death5 UMETA(DisplayName = "Death5"),
	EDP_Death6 UMETA(DisplayName = "Death6")
};

// Enemy 상태
UENUM(BlueprintType)
enum class EEnemyStates : uint8
{
	EES_Dead UMETA(DisplayName = "Dead"),
	EES_Patrolling UMETA(DisplayName = "Patrolling"),
	EES_Chasing UMETA(DisplayName = "Chasing"),
	EES_Attacking UMETA(DisplayName = "Attacking"),
    EES_Engaged UMETA(DisplayName = "Engaged")
};
  • Enemy.h
...
/**
* ENUM 변수
*/
// DeathAnimation 종류
UPROPERTY(BlueprintReadOnly, meta = (AllowPrivateAccess = "true"))
EDeathPose DeathPose = EDeathPose::EDP_Alive;

// Enemy State
UPROPERTY(BlueprintReadOnly, meta = (AllowPrivateAccess = "true"))
EEnemyStates EnemyState = EEnemyStates::EES_Patrolling;

컴파일 후, ABP_Paladin 으로 들어가서 EEnemyState 타입 변수 EnemyState 를 생성한다.
Blueprint Thread 에서 업데이트를 하기 위해 노드를 추가한다.EnemyStatesetter 노드를 추가하고, Property Access 노드를 통해 코드상의 EnemyState에 접근한 뒤 노드를 연결해준다.
Idle state에서 Deat state로 넘어가는 Transition RuleBlueprint Thread 에서 업데이트한 EEnemyState::EES_Dead 로 수정한다.
이제 ABP_Paladin 에서 EDeathPase::EDP_Alive 를 사용하지 않으므로 코드상에서 제거 가능하다.

  • Enemy.h
...
private:
	/**
	* ENUM 변수
	*/
	// DeathAnimation 종류
	UPROPERTY(BlueprintReadOnly, meta = (AllowPrivateAccess = "true"))
	EDeathPose DeathPose;
    ...
  • CharacterTypes.h
// Enemy DeathAnimation에 따른 DeadAnimation
UENUM(BlueprintType)
enum class EDeathPose : uint8
{
	EDP_Death1 UMETA(DisplayName = "Death1"),
	EDP_Death2 UMETA(DisplayName = "Death2"),
	EDP_Death3 UMETA(DisplayName = "Death3"),
	EDP_Death4 UMETA(DisplayName = "Death4"),
	EDP_Death5 UMETA(DisplayName = "Death5"),
	EDP_Death6 UMETA(DisplayName = "Death6")
};
  1. CheckCombatTarget() 함수 수정
    Enemy 클래스에 있는 CheckCombetTarget() 의 코드가 너무 난잡하므로 해당 부분도 리팩토링을 진행한다.
    첫번째 if문 내에 있는
if(HealthBarWidget)
{
	HealthBarWidget->SetVisibility(false);
}

부분은 추후에 visibility 를 조건에 따라 true 또는 false 로 변경시켜야 할 수 있으므로 함수로 따로 리팩토링 하는 것이 좋아 보인다.
그리고 어그로 제거 함수를 따로 만들어서, CombatTarget 을 nullptr로 할당해주고, HealthBarWidgetvisibility 를 설정하는 함수를 같이 넣어준다.

  • Enemy.h
...
protected:
	/**
    * AI Behavior
    */
	// HealthBarWidget 숨기는 함수
	void HideHealthBarWidget();
    
    // HealthBarWidget 나타내는 함수
    void ShowHealthBarWidget();
    
    // 어그로 제거 함수
    void LoseInterest();
    ...
  • Enemy.cpp
...
// HealthBarWidget 숨기는 함수
void AEnemy::HideHealthBarWidget();
{
	if(HealthBarWidget)
    {
    	HealthBarWidget->SetVisibility(false);
    }
}

// HealthBarWidget 숨기는 함수
void AEnemy::ShowHealthBarWidget()
{
 	if(HealthBarWidget)
    {
    	HealthBarWidget->SetVisibility(true);
    }
}

// 어그로 제거 함수
void AEnemy::LoseInterest()
{
	CombatTarget = nullptr;
    HideHealthBarWidget();
}
...

첫번째 if문 내에 있는 다른 부분도 수정한다.
MaxWalkSpeed 의 경우 다른 적에게 다른 값을 할당하고 싶을 수 있으므로 추가수정이 필요하다.

  • Enemy.h
...
protected:
	...
	/**
    * AI Behavior
    */
    ...
    // 패트롤 시작 함수
    void StartPatrolling();
    
	...
    // 패트롤시 이동 속도
    UPROPERTY(EditAnywhere, Category = "Combat")
    float PatrollingSpeed = 125.f;
    
    // 추적시 이동 속도
    UPROPERTY(EditAnywhere, Category = "Combat")
    float ChasingSpeed = 300.f;
	...
  • Enemy.cpp
...
// 패트롤 시작 함수
void AEnemy::StartPatrolling()
{
	EnemyState = EEnemyStates::EES_Patrolling;
	GetCharacterMovement()->MaxWalkSpeed = PatrollingSpeed;
	MoveToTarget(PatrolTarget);
}

첫번째 if문에 존재하는 조건문도 간단하게 함수값을 리턴받도록 한다.

  • Enemy.h
	protected:
	/**
    * AI Behavior
    */
    ...
    // CombatTarget이 CombatRadius 외부에 존재하는지 확인하는 함수, 외부존재시 true 반환
    bool IsOutsideCombatRadius();
    ...
  • Enemy.cpp
...
// CombatTarget이 CombatRadius 외부에 존재하는지 확인하는 함수
void AEnemy::IsOutsideCombatRadius()
{
	// CombatRadius 내부에 CombatTarget이 없을 경우 true 반환
    return !InTargetRange(CombatTarget, CombatRadius)
}

공격 도중 CombatRange 를 탈출하게 되면 AttackTimer 를 clear 해주어야 한다. 그렇지 않으면 타이머가 계속 돌아가는 문제가 발생한다.
동일하게 패트롤을 시작하려면 공격하지 않는 상태여야 한다. 해당부분을 수정해야 한다,
(번역시 Attack을 more aggressive, Engage를 actually swing sworld라고 설명, 코드상 EES_Attacking은 공격 가능한 상태를 의미하고 EES_Engaged는 실제 공격중인 상태를 의미하는듯함)

  • Enemy.h
...
protected:
	/**
    * AI Behavior
    */
    // Engaged 여부 확인 함수
    bool IsEngaged();
    ...
    
    
    ...
    // AttackTimer clear 함수
    void ClearAttackTimer();
    ...
  • Enemy.cpp
...
// AttackTimer clear 함수
void AEnemy::ClearAttackTimer()
{
	GetWorldTimerManager().ClearTimer(AttackTimer);
}
...
// Engaged 여부 확인 함수
bool AEnemy::IsEngaged()
{
	return EnemyState == EEnemyStates::EES_Engaged;
}
...

이제 리팩토링한 부분들을 이용해 첫번째 if문을 수정하면 아래와 같다.

  • Enemy.cpp
void AEnemy::CheckCombatTarget()
{
	// CombatRadius 외부에 타겟 존재시
	if(IsOutsideCombatRadius())
    {	
    	ClearAttackTimer();
    	LoseInterest();
        if(!IsEngaged())
        {
        	StartPatrolling();
        }
    }
    ...
}

위와 같이 첫번째 else if 도 똑같이 리팩토링을 진행한다.

  • Enemy.h
...
protected:
	...
	/**
    * AI Behavior
    */
    ...
    // 추적 시작 함수
    void StartChasing();
    
    // 추적 여부 리턴 함수
    bool IsChasing();
	
    // CombatTarget이 AttackRadius 외부에 존재하는지 확인하는 함수, 외부존재시 true 반환
    bool IsOutsideAttackRadius();
  • Enemy.cpp
...
void AEnemy::StartChasing()
{
	EnemyState = EEnemyState::EES_Chasing;
    GetCharacterMovement()->MaxWalkSpeed = ChasingSpeed;
	MoveToTarget(CombatTarget);
}

// CombatTarget이 AttackRadius 외부에 존재하는지 확인하는 함수
bool AEnemy::IsOutsideAttackRadius()
{
	return !InTargetRange(CombatTarget, AttackRadius)
}

// 추적 여부 리턴 함수
bool AEnemy::IsChasing()
{
	return EnemyState == EEnemyStates::EES_Chasing;
}

이제 리팩토링한 부분들을 이용해 첫번째 else if문을 수정하면 아래와 같다.

  • Enemy.cpp
void AEnemy::CheckCombatTarget()
{
	if(...) { ... }
    // AttackRadius 외부에 타겟 존재 및 추적상태가 아닐 시
    else if (IsOutsideAttackRadius() && !IsChasing())
	{
    	ClearAttackTimer();
        if(!IsEngaged)
        {
			StartChasing();
        }
	}
    else if(...) { ... }
}

동일하게 두번째 else if 도 똑같이 리팩토링을 진행한다. 진행하면서 동시에 타이머를 추가해 공격에 텀을 둠으로써, 살짝 이동시 공격 애니메이션이 중지되는 것을 방지하게 할 것이다.

  • Enemy.h
...
protected:
	...
	/**
    * AI Behavior
    */
    ...
    // CombatTarget이 AttackRadius 내부에 존재하는지 확인하는 함수
    bool IsInsideAttackRadius();
    
    // 공격가능 여부 리턴 함수
    bool IsAttacking();
    
    // AttackTimer 시작 함수
    void StartAttackTimer();
    
    // 공격 딜레이 타이머
    FTimerHandle AttackTimer;
    
    // 공격 딜레이 최소시간
    UPROPERTY(EditAnywhere, Category = "Combat")
    float AttackMin = 0.5f;
    
    // 공격 딜레이 최대시간
    UPROPERTY(EditAnywhere, Category = "Combat")
    float AttackMax = 1.f;
	...
  • Enemy.cpp
...
// CombatTarget이 AttackRadius 내부에 존재하는지 확인하는 함수
bool AEnemy::IsInsideAttackRadius()
{
	return InTargetRange(CombatTarget, AttackRadius);
}

// 공격 여부 리턴 함수
bool AEnemy::IsAttacking()
{
	return EnemyState == EEnemyStates::EES_Attacking;
}

// AttackTimer 시작 함수
void AEnemy::StartAttackTimer()
{
	EnemyState = EnemyState::EES_Attacking;
    const float AttackTime = FMath::RandRange(AttackMin, AttackMax);
    GetWorldTimerManager().SetTimer(AttackTimer, this, &AEnemy::Attack, AttackTime)
    
    
}
...

이제 리팩토링한 부분들을 이용해 두번째 else if문을 수정하면 아래와 같다.

  • Enemy.cpp
void AEnemy::CheckCombatTarget()
{
	if(...) { ... }
    else if (...) { ... }
    // AttackRadius 내부에 타겟 존재 및 공격상태가 아닐 시
    else if(IsInsideAttackRadius() && !IsAttacking())
    {
    	StartAttackTimer();
    }
}

마지막으로 한번더 else if 내의 조건문을 수정할 수 있다.
BaseCharacter 클래스에는 CanAttack() 이라는 함수가 있다.
override해서 사용한다.

  • Enemy.h
...
protected:
	...
    // CanAttack 함수 override
    virtual void CanAttack() override;
    ...
  • Enemy.cpp
...
void AEnemy::CanAttack()
{
	bool bCanAttack = IsInsideAttackRadius() && !IsAttacking() && !IsDead();
    return bCanAttack;
}

최종적으로 리팩토링한 CheckCombatTarget() 함수는 아래와 같다.

  • Enemy.cpp
...
void AEnemy::CheckCombatTarget()
{
	// CombatRadius 외부에 타겟 존재시
	if(IsOutsideCombatRadius())
    {
    	LoseInterest();
        StartPatrolling();
    }
	// AttackRadius 외부에 타겟 존재 및 추적상태가 아닐 시
    else if (IsOutsideAttackRadius() && !IsChasing())
	{
		StartChasing();
	}
	// AttackRadius 내부에 타겟 존재 및 공격상태가 아닐 시
	else if(CanAttack())
    {
    	StartAttackTimer();
    }
}
...

보다 직관적으로 깔끔하게 정리된 것을 확인할 수 있다.

  1. PawnSeen() 함수 정리
  • Enemy.h
...
protected:
	// 사망 여부 리턴 함수
    bool IsDead();
	...
    // PatrolTimer clear 함수
    void ClearPatrolTimer();
    ...
  • Enemy.cpp
...
bool AEnemy::IsDead()
{
	return EnemyState == EEneyStates::EES_Dead;
...
// PatrolTimer clear 함수
void AEnemy::ClearPatrolTimer()
{
	GetWorldTimerManager().ClearTimer(PatrolTimer);
}
...
// Enemy가 캐릭터를 발견했을 경우 실행
void AEnemy::PawnSeen(APawn* SeenPawn)
{
	// if문 하나로 묶어서 사용할 조건
	const bool bShouldChaseTarget = 
    	EnemyState != EEnemyState::EES_Dead &&
        EnemyState != EEnemyState::EES_Chasing && 
        EnemyState < EEnemyState::EES_Attacking && 
        SeenPawn->ActorHasTag(FName("WraithCharacter"));
    
    if(bShouldChaseTarget)
    {
    	CombatTarget = SeenPawn;
		ClearPatrolTimger();
        ChasingTarget();
    }
}
  1. Tick() 함수 수정
    EEnemyStates::EES_Dead 가 추가되었으므로, 코드를 수정해야한다.
  • Enemy.h
...
protected:
	/**
    * AI Behaivor
    */
    ...
    // 사망 여부 확인 함수
    bool IsDead();
  • Enemy.cpp
...
void AEnemy::Tick(float DeltaTime)
{
	Super::Tick(DeltaTime);
    
    if(IsDead()) return;
    ...
}
...
// 사망 여부 확인 함수
bool AEnemy::IsDead()
{
	return EnemyState == EEnemyStates::EES_Dead;
}
  1. GetHit_Implementation(...) 함수 수정
    우선 맨 위쪽에 위치한 HealthBarWidget->SetVisibility(true) 의 경우 기존에 생성해둔 ShowHealthBarWidget() 함수로 대체 가능하다.
    나머지 부분은 부모클래스인 BaseCharacter 에서의 수정이 필요하다.
  • BaseCharacter.h
...
protected:
	...
    // 생존 여부 확인 함수
    virtual bool IsAlive();
    
    // HitSound 재생 함수
    void PlayHitSound(const FVector& ImpactPoint);
    
    // HitParticles 스폰 함수
    void SpawnHitParticles(const FVector& ImpactPoint);
    ...
private:
	...
    // 피격 사운드
	UPROPERTY(EditAnywhere, Category = "Sounds")
	USoundBase* HitSound;

	// 피격시 visual effects
	UPROPERTY(EditAnywhere, Category = "VisualEffects")
	UParticleSystem* HitParticles;
    ...
  • BaseCharacter.cpp
...
#include "Kismet/GameplayStatics.h"
...
// 생존 여부 확인 함수
bool ABaseCharacter::IsAlive()
{
	return Attributes && Attributes->IsAlive();
}

// HitSound 재생 함수
void PlayHitSound(const FVector& ImpactPoint)
{
	if(HitSound)
    {
		UGameplayStatics::PlaySoundAtLocation(this, HitSound, ImpactPoint);
    }
}    

// HitParticles 스폰 함수
void SpawnHitParticles(const FVector& ImpactPoint)
{
	if(HitParticles && GetWorld())
    {
    	UGameplayStatics::SpawnEmitterAtLocation(GetWorld(), HitParticles, ImpactPoint);
    }
}
  • Enemy.cpp
...
// #include "Kismet/GameplayStatics.h" BaseWeapon 클래스에서 include하기때문에 필요없음
...
void AEnemy::GetHit_Implementation(const FVector& ImpactPoint)
{
	ShowHealthBarWidget();
    if(IsAlive())
    {
    	...
    }
    else
    {
    	Die();
    }
    
    PlayHitSound(ImpactPoint);
    SpawnHitParticles(ImpactPoint);
}
  1. TakeDamage() 함수 수정
  • BaseCharacter.h
...
protected:
	...
    // 데미지 받는 기능 함수
    virtual void HandleDamage(float DamageAmount);
	...
  • BaseCharacter.cpp
...
// 데미지 받는 기능 함수
void ABaseCharacter::HandleDamage(float DamageAmount)
{
	if(Attributes)
    {
    	Attributes->ReceiveDamage(DamageAmount);
    }
}
  • Enemy.h
...
protected:
	...
    virtual void HandleDamage(float DamageAmount) override;
    ...
  • Enemy.cpp
...
void AEnemy::HandleDamage(float DamageAmount)
{
	Super::HandleDamage(DamageAmount);
    
    if(AttributEs)
    {
        HealthBarWidget->SetHealtPercent(Attributes->GetHealthPercent());
    }
}

마지막으로 수정하면 아래와 같다.

  • Enemy.cpp
...
float AEnemy::TakeDamage(...)
{
	HandleDamage(DamageAmount);
    CombatTarget = EventInstigator->GetPawn();
    StartChasing();
    return DamageAmount;
}
  1. PlayAttackMontage() 함수 수정 및 PlayDeathMontage() 추가
    매번 랜덤한 값을 통해 SectionNameswitch 를 통해 정해주고, 애니메이션 몽타주를 재생하는 것은 앞으로 여러 적을 구현할 때 매우 비효율적이다.
    그렇기에 리팩토링을 통해 좀더 편하게 개발할수 있도록 수정해야한다.
  • BaseCharacter.h
...
protected:
...
	// Montage SectionName 재생 함수
    void PlayMontageSection(UAnimMontage* Montage, const FName& SectionName)
    
    // 랜덤 몽타주 섹션 재생 함수
    int32 PlayRandomMontageSection(UAnimMontage* Montage, const TArray<FName>& SectionNames);
    
    // AttackMontage SelectionNumber 반환 함수
    virtual int32 PlayAttackMontage();
    ...
    
    
    /**
    * 애니메이션 몽타주
    */
    ...
    // AttackMontage Section명 배열
    UPROPERTY(EditAnywhere, Category = "Combat")
    TArray<FName> AttackMontageSections;
  • BaseCharacter.cpp
...
// Montage SectionName 재생 함수
void ABaseCharacter::PlayMontageSection(UAnimMontage* Montage, const FName& SectionName)
{
	UAnimInstance* AnimInstnace = GetMesh()->GetAnimInstance();
    if(AnimInstance && Montage)
    {
    	AnimInstance->Montage_Play(Montage);
        AnimInstance->Montage_JumpToSection(SectionName, Montage);
    }
}

// 랜덤 몽타주 섹션 재생 함수
int32 ABaseCharacter::PlayRandomMontageSection(UAnimMontage* Montage, const TArray<FName>& SectionNames)
{
	if(SectionNames.Num() <= 0) return -1;
    const int32 MaxSectionIndex = SectionNames.Num() - 1;
    const int32 Selection = FMath::RandRange(0, MaxSectionIndex);
    PlayMontageSection(Montage, SectionNames[Selection]);
	return Selection;
}

// AttackMontage 재생 함수
int32 ABaseCharacter::PlayAttackMontage()
{
	return PlayRandomMontageSection(AttackMontage, AttackMontageSections)
}

...

이제 Enemy.hWraithCharacter.h 에 있는 PlayAttackMontage() 함수를 전부 삭제한다.
그리고 Enemy.cppWraithCharacter.cpp 에 있는 PlayAttackMontage() 도 삭제한다.
BP_Paladin 에 들어가서 Attack1 ~ Attack3 까지 추가해준다.
BP_WraithCharacter 에서도 똑같이 Attack1Attack2 를 추가해준다.
컴파일 후 실행하면 정상적으로 작동하는 것을 확인할 수 있다.

이제 PlayDeathMontage 도 추가할 수 있다.

  • BaseCharacter.h
...
protected:
	...
    // DeathMontage SelectNumber 반환 함수
    virtual int32 PlayDeathMontage();
    ...
    
    // DeathMontage Section명 배열
    TArray<FName> DeathMontageSections;
  • BaseCharacter.cpp
...
int32 ABaseCharacter::PlayDeathMontage()
{
	return PlayRandomMontageSection(DeathMontage, DeathMontageSections);
}

동일하게 Enemy 클래스에서 관련된 코드를 수정한다.

  • Enemy.h
...
protected:
	...
    // PlayDeathMontage함수 override
    virtual int32 PlayDeathMontage() override;
    ...
  • Enemy.cpp
...
// DeathMontage SelectionNumber 반환 함수, EDeathPose를 byte단위로 캐스팅해서 Pose에 할당
int32 AEnemy::PlayDeathMontage()
{
	const int32 Selection = Super::PlayDeathMontage();
    // TEnumAsByte는 ENUM 타입 변수값을 byte로 캐스팅함으로써 메모리를 절약하면서 상태를 표시할 수 있음
    // EES_Dead = 0, EES_Chasing = 1 이런식으로 정수를 상수화해서 나타내는데
    // 이를 byte로 캐스팅함으로써 메모리 절약이 가능하다
    // CharacterTypes.h 에 EDP_MAX UMETA(DisplayName = "DefaultMAX") 코드 추가
    TEnumAsByte<EDeathPase> Pose(Selection);
    // 배열의 index를 확인하는것과 같은 과정, EDP_MAX를 추가함으로써 index 크기를 확인하고 할당 가능
    if(Pose < EDeathPose::EDP_MAX)
    {
    	DeathPose = Pose;
    }
    
    return Selection;
}

TEnumAsByte를 사용하기 위해 CharacterTypes.h 를 변경해야 한다.

  • CharacterTypes.h
    TEnumAsByte 를 사용하려면 non-scoped enum 형식으로 변경해야되고 그러려먼 기존의 enum을 사용해야 함.
    설명을 찾아보니 개선된 것이 scoped enum 이고 강력한 형식 안정성을 가지게 된다고 함.
...
enum EDeathPase
{
	EDP_Death1 UMETA(DisplayName = "Death1"),
    EDP_Death2 UMETA(DisplayName = "Death2"),
    EDP_Death3 UMETA(DisplayName = "Death3"),
    EDP_Death4 UMETA(DisplayName = "Death4"),
    EDP_Death5 UMETA(DisplayName = "Death5"),
    EDP_Death6 UMETA(DisplayName = "Death6"),
    
    EDP_MAX UMETA(DisplayName = "DefaultMAX")
}
...

EDeathPose 가 변경되었으므로 Enemy.h 의 변수선언도 변경이 필요하다.

...
protected:
	UPROPERTY(BlueprintReadOnly)
    TEnumAsByte<DeathPose> DeathPose;
    ...
  1. Die() 함수 수정
    PlayDeathMontage 를 수정했으니 이제 Die() 함수를 수정해야 한다.
  • Enemy.cpp
...
void AEnemy::Die()
{
	EnemyState = EEnemyStates::EES_Dead;
    // AnimInstance 호출 및 if(AnimInstance && DeathMontage) 삭제
    PlayDeathMontage();
    ClearAttackTimer();
    // if문 내용을 생성해둔 HideHealthBarWidget() 함수로 교체
	HideHealthBarWidget();
    // 사망시, 사망 애니메이션이 캐릭터움직임에 따라 회전하지 않도록 방지
    GetCharacterMovement()->bOrientRotationToMovement = false;
    GetCapsuleComponent()->SetCollisionEnabled(ECollisionEnabled::NoCollision);
    SetLifeSpan(3.f);
}
...

캡슐 컴포넌트의 콜리전을 제거하는 것도 여러곳에서 사용할 수 있는 기능이므로 리팩토링을 진행한다.

  • BaseCharacter.h
...
protected:
	...
    // 캡슐 컴포넌트 콜리전을 NoCollision으로 변경
	void DisableCapsuleCollision();
    ...
private:
	...
    // 사망시 액터 삭제까지 소요시간
    UPROPERTY(EditAnywhere, Category = "Combat" , meta = (AllowPrivateAccess = "true"))
    float DeathLifeSpan = 6.f;
  • BaseCharacter.cpp
...
#include "Components/CapsuleComponent.h"
...
void ABaseCharacter::DisableCapsuleCollision()
{
	GetCapsuleComponent->SetCollisionEnabled(ECollisionEnabled::NoCollision);
}
...

최종적으로 수정한 Die() 함수는 아래와 같다.

  • Enemy.cpp
...
void AEnemy::Die()
{
	EnemyState = EEnemyStates::EES_Dead;
	PlayDeathMontage();
    HideHealthBarWidget();
    DisableCapsuleCollision();
    SetLifeSpan(DeathLifeSpan);
    // 사망시, 사망 애니메이션이 캐릭터움직임에 따라 회전하지 않도록 방지
    GetCharacterMovement()->bOrientRotationToMovement = false;
}
...

컴파일 후 실행하여 BP_Paladin 에서 DeathMontageSections 에 배열 6개와 DeathPose 6개를 추가해준다.
추가로 지점 도착시 움찔거리는 문제는 Weight Speed 값을 조절하면 해결된다.

실행하면 공격을 한번하고 이어서 공격하는 코드가 없기 때문에 공격을 하지 않는다. 해당 부분을 먼저 수정하고 리팩토링 작업을 계속 한다.

  • Enemy.cpp
...
void AEnemy::Attack()
{
	Super::Attack();
    
    EnemyState = EEnemyStates::EES_Engaged;
    PlayAttackMontage();
}
...

이미 Engaged 상태(공격중인 상태)에서는 공격을 추가로 진행하면 안되므로 관련 조건을 수정해야 한다. Attack() 함수는 StartAttackTimer() 함수에서 콜백함수로 호출되고, StartAttackTimer()CheckCombatTarget() 함수에서 if(CanAttack()) 조건을 만족할 시 호출된다.
그러므로 이미 EngagedCanAttack() 함수에서 조건을 추가해주어야 한다.

  • Enemy.cpp
...
bool AEnemy::Attack()
{
	bool bCanAttack = IsInsideAttackRadius() &&
    	!IsAttacking() &&
    	!IsEngaged() &&
    	!IsDead();
    return bCanAttack;
}
...

또한 언제 Engaged 상태에서 공격이 끝나면 캐릭터의 위치에 따라 적의 상태를 변경시켜야 한다.
해당 기능은 AttackEnd() 함수에서 진행하면 되므로, BaseCharacter 클래스에 있는 함수를 override 해서 사용한다.

  • Enemy.h
...
protected:
	...
    // AttackEnd 함수 override
    virtual void AttackEnd() override;
    ...
  • Enemy.cpp
...
void AEnemy::AttackEnd()
{
	// 바로 다른 상태로 변경시 해당 state의 조건문에 의해 오류가 생길수 있으므로
    // 아무것도 하지 않는 상태로 변경후 CheckCombatTarget() 호출해서 상태 변경
	EnemyState = EEnemyStates::EES_NoState;
    CheckCombatTarget();
}
...

그리고 CharacterTypes 클래스에 NoState 를 추가해준다.

  • CharacterTypes.h
...
// Enemy 상태
UENUM(BlueprintType)
enum class EEnemyStates : uint8
{
	EES_Dead UMETA(DisplayName = "Dead"),
	EES_Patrolling UMETA(DisplayName = "Patrolling"),
	EES_Chasing UMETA(DisplayName = "Chasing"),
	EES_Attacking UMETA(DisplayName = "Attacking"),
	EES_Engaged UMETA(DisplayName = "Engaged"),
    
    EES_NoState UMETA(DisplayName = "NoState")
};

컴파일 후 AM_Attack 파일을 열고, AttackEnd 노티파이를 추가해준 뒤
ABP_PaladinEventGraph 로 돌아가서 AnimNotify_AttackEnd 노드를 생성하고, 유효성 검사 노드와 연결시킨다.
그리고 성공시 AttackEnd() 함수를 호출하도록 노드를 연결시킨다.
컴파일 후 실행시 CheckCombatTarget() 내의 조건에 의해 행동하는 것을 확인할 수 있다.

0개의 댓글